Compare commits
227 Commits
41854fe5bd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa1004ec5 | |||
| c4632bacf1 | |||
| 7dcaae594a | |||
| 999bf5bd01 | |||
| b281045df5 | |||
| 35acb1c7e7 | |||
| bbbd9cf3e0 | |||
| 5dc5ef9df5 | |||
| fe8f6c1c26 | |||
| 30e8227099 | |||
| 588da4958f | |||
| bc4754288e | |||
| 6b0cdbcecc | |||
| 5ff05e0d3d | |||
| bab4d47b46 | |||
| fdd26667c9 | |||
| 29522b69fe | |||
| a54489fe92 | |||
| 20c14437c6 | |||
| dce5454ec8 | |||
| 72b64451ac | |||
| d17d67d6ca | |||
| 63c577bd76 | |||
| 8c190597ae | |||
| ad063264cf | |||
| 0108c91bae | |||
| ee2865829d | |||
| e66460c4e9 | |||
| 2811886a52 | |||
| 3b5a7cd318 | |||
| 3fe2da3ee0 | |||
| ee77028907 | |||
| 1bcaa0b3d8 | |||
| 1703a7aa5e | |||
| e3f1028667 | |||
| ea58ab2166 | |||
| 3b1aa2ebcb | |||
| fda2e70456 | |||
| 3cea91467d | |||
| dd6b37be16 | |||
| 078839b0f3 | |||
| 326438aac9 | |||
| 30da6eaead | |||
| 3784246e6b | |||
| fa6f189cdd | |||
| daa9d8d341 | |||
| c85afb59ab | |||
| 7d293f092e | |||
| da27204a71 | |||
| bac721e593 | |||
| bc666c6433 | |||
| 4a71992752 | |||
| 1b78186c4e | |||
| cb97aa193d | |||
| a02ac6e853 | |||
| f3cc790659 | |||
| 5ffbf76565 | |||
| 04b45adf98 | |||
| 9714c051c5 | |||
| fbf5a6d61a | |||
| 94632f42d6 | |||
| 3f6017d98b | |||
| d5af51c02b | |||
| 1ec0bb65f7 | |||
| c934a948c6 | |||
| 950bcaa7aa | |||
| 728ed7998d | |||
| 757b5665ca | |||
| c280bbc1c3 | |||
| 68c186f649 | |||
| f2de8b281a | |||
| 19f725dbaa | |||
| df7a1ee382 | |||
| 4fe1f0d77c | |||
| f45b3a71fa | |||
| 1aa7c28616 | |||
| e542e52bdb | |||
| 0afbdc2210 | |||
| fc6dea12eb | |||
| c3f88ebf12 | |||
| 1d1c93f8e2 | |||
| 929b105173 | |||
| e00777ebc2 | |||
| e4f6cdd025 | |||
| 2a705a361a | |||
| b4ec5da808 | |||
| 4d8761cc3c | |||
| fdd0dd0525 | |||
| df29c8d3ec | |||
| ef24402747 | |||
| 78e872b609 | |||
| 27e523a1dc | |||
| f9d9f06d3f | |||
| d45f39f1bf | |||
| 5fa3d3bf54 | |||
| 7bc832f3c6 | |||
| 46c647d676 | |||
| 24b961ba61 | |||
| d2cf021194 | |||
| 0542a44afe | |||
| 3f14d9de54 | |||
| b8e0d3bf4c | |||
| f1a6a2695d | |||
| 54ccfe7e0a | |||
| ce041e0d16 | |||
| 81f6b7d1a5 | |||
| dd376e4b32 | |||
| f927ef8c0f | |||
| 0d831c4ca6 | |||
| 96d0cc6fc4 | |||
| c7d213bf35 | |||
| b5934085da | |||
| 3657d83833 | |||
| 94a1422a35 | |||
| 5bc9753766 | |||
| a838732e5a | |||
| 4b88defdab | |||
| bc228980af | |||
| e0b4a3f56c | |||
| 840473362d | |||
| 630a7a16c9 | |||
| f5de3a2c29 | |||
| c5767ca272 | |||
| a7f96c030f | |||
| 5a7c49a454 | |||
| cda249e9b4 | |||
| 52c7831bf6 | |||
| 5122c73aa8 | |||
| 5f08978827 | |||
| 2135a5bd03 | |||
| 199f988b2e | |||
| 0f1d49bf63 | |||
| c7f5188354 | |||
| 6a1c60b2e2 | |||
| 2aa330786a | |||
| b65b3ed8f1 | |||
| f9ec86436a | |||
| 42999ffa2c | |||
| e060af380e | |||
| b55ac0bf81 | |||
| 6b5d57675e | |||
| c225d8484f | |||
| 15c8f1f403 | |||
| 52f9ee9d92 | |||
| d7e11be01f | |||
| 866fe32a8c | |||
| d89dd24f0a | |||
| 6a304e2fc2 | |||
| b2da70897a | |||
| 12ad2b163a | |||
| 1e8e738eda | |||
| 7f7fce1fc3 | |||
| a2d9e910ff | |||
| 29faf7932e | |||
| 0af4cd7f68 | |||
| 0e3c2028d0 | |||
| c2e9c11a9a | |||
| 95503d418c | |||
| 0c2681e699 | |||
| ab87e18ba5 | |||
| 33808d9ba9 | |||
| 1f8a0fdc44 | |||
| 229ca8332d | |||
| 1b449287fd | |||
| 05085b961b | |||
| cfb19b5783 | |||
| 6f1e40754d | |||
| d79678d850 | |||
| 224adf70d4 | |||
| f2105399c5 | |||
| eb8b14602a | |||
| df52f90d46 | |||
| 05e67c73df | |||
| 32e4030442 | |||
| be2f624e3c | |||
| 75ab0c06c6 | |||
| 1410aa588a | |||
| 43b15b2098 | |||
| 004a46cb41 | |||
| 76b1d6c38b | |||
| abe4fed452 | |||
| a35137ed1c | |||
| 7f17fa1fb4 | |||
| 5f7321da58 | |||
| e5646ef80c | |||
| 71b3517003 | |||
| 6f81e1940a | |||
| 98a74f7bb4 | |||
| 5b2a03d416 | |||
| e469b0ddf4 | |||
| 1f02e78b24 | |||
| a235f46b6d | |||
| 05953f857a | |||
| 6538742dfa | |||
| 1f10a6bb3d | |||
| 1df0081479 | |||
| db155e7ad0 | |||
| 6d458dbbc6 | |||
| f7708331d3 | |||
| 873cdb9d31 | |||
| 52b07be19b | |||
| 914820cc07 | |||
| 47d1520d72 | |||
| 8ed22897ea | |||
| 3963648f1a | |||
| 2c711871f5 | |||
| 836f56af41 | |||
| e27375a204 | |||
| 92322349d4 | |||
| 1a79dc483c | |||
| a3a077fa87 | |||
| b15b93eec1 | |||
| 1215a04b7d | |||
| d014bb2fbd | |||
| dff791b51f | |||
| ce46e42ff7 | |||
| cf1b60c996 | |||
| d0ca09d4d8 | |||
| c1e66b31a5 | |||
| cb18a3f279 | |||
| b634540047 | |||
| cd116bc3ae | |||
| f839b16dbd | |||
| 70d72d5d02 | |||
| f3a37157fc | |||
| ce5564408d | |||
| d5265619f3 |
Generated
+80
@@ -37,6 +37,15 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
@@ -99,6 +108,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
@@ -130,6 +148,26 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "either"
|
||||
version = "1.17.0"
|
||||
@@ -153,6 +191,8 @@ dependencies = [
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ta-lib",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
@@ -162,6 +202,16 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -431,6 +481,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -477,6 +538,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ta-lib"
|
||||
version = "0.8.1"
|
||||
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
|
||||
dependencies = [
|
||||
"ta-lib-dispatch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ta-lib-dispatch"
|
||||
version = "0.1.2"
|
||||
source = "git+https://github.com/TA-Lib/ta-lib.git?rev=dd5a90259a3f9e04e2da9f38bf0719a841b40108#dd5a90259a3f9e04e2da9f38bf0719a841b40108"
|
||||
|
||||
[[package]]
|
||||
name = "thin-vec"
|
||||
version = "0.2.16"
|
||||
@@ -512,6 +586,12 @@ dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ version = "0.1.0"
|
||||
authors = ["OpenAI Codex"]
|
||||
|
||||
[workspace.dependencies]
|
||||
sha2 = "=0.10.9"
|
||||
ahash = "=0.8.12"
|
||||
chrono = { version = "=0.4.44", features = ["serde"] }
|
||||
indexmap = { version = "=2.11.4", features = ["serde"] }
|
||||
@@ -18,5 +19,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
|
||||
rayon = "=1.12.0"
|
||||
rhai = { version = "=1.23.6", features = ["sync"] }
|
||||
serde = { version = "=1.0.228", features = ["derive"] }
|
||||
serde_json = "=1.0.145"
|
||||
serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
|
||||
thiserror = "=2.0.18"
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
||||
|
||||
## Runtime position exposure schedule
|
||||
|
||||
`strategy_spec.runtimeExpressions.risk.positionExposureSchedule` accepts dated
|
||||
`effectiveDate` plus `targetExposureBps` points. The platform expression strategy
|
||||
uses the latest point whose date is not later than the current execution date and
|
||||
otherwise keeps the strategy's normal `exposureExpr`. This contract is intended for
|
||||
audited runtime controls replayed by paper/live shadow reconciliation; it is not a
|
||||
market-data signal and does not change selection, pricing, fees, or execution-day
|
||||
risk checks.
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 日频和分钟执行价策略生命周期与确定性回放。
|
||||
@@ -75,7 +85,7 @@
|
||||
- `selection.market_cap_band(...)` 动态市值带。
|
||||
- `filter.stock_expr(...)` 任意指标、因子和组合选股。
|
||||
- `ordering.rank_by(...)` 与 `ordering.rank_expr(...)` 排序。
|
||||
- `allocation.buy_scale(...)` 动态买入资金比例。
|
||||
- `allocation.buy_scale(...)` 相对等权槽位的个股资金倍率;显式权重可以大于 `1.0`,组合总仓位仍由 `risk.index_exposure(...)` 和严格资金预算控制。
|
||||
- `risk.stop_loss(...)`、`risk.take_profit(...)` 多条件止盈止损。
|
||||
- `order.*`、`cancel.*`、`update_universe(...)`、`subscribe(...)` 显式交易动作。
|
||||
|
||||
@@ -89,6 +99,8 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与
|
||||
|
||||
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
||||
|
||||
`targetPortfolioDaily=true` 时,每只股票的默认目标金额固定为 `target_budget / selection_limit * buy_scale`,候选不足、缺行情或风控拒绝产生的剩余资金保留为现金,不得自动归一到满仓。止盈、止损或最大持有期触发后,标的从活动目标顺序移除;卖出未完成时继续占用仓位槽且不得反向补买,成功释放的槽位只能由同一决策时点已排序且通过策略条件的后续候选补充。只有显式设置 `redistributeTargetWeightsAfterExit=true` 才在可用目标间重新分配权重,只有显式设置 `reenterExitedTargets=true` 才保留退出标的供后续重新入场;两个开关互相独立,默认都为 `false`。
|
||||
|
||||
## 内置微盘策略
|
||||
|
||||
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
|
||||
|
||||
@@ -13,4 +13,6 @@ rayon.workspace = true
|
||||
rhai.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use fidc_core::factor_events::{self, Expr, Frame};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, Read};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
expressions: std::collections::BTreeMap<String, Expr>,
|
||||
frame: Frame,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut input = String::new();
|
||||
io::stdin().read_to_string(&mut input)?;
|
||||
let output = if input.trim().is_empty() {
|
||||
factor_events::catalog()
|
||||
} else {
|
||||
let request: Request = serde_json::from_str(&input)?;
|
||||
let results = request
|
||||
.expressions
|
||||
.iter()
|
||||
.map(|(id, expr)| {
|
||||
let result = match factor_events::evaluate(expr, &request.frame) {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e}),
|
||||
};
|
||||
(id.clone(), result)
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<String, Value>>();
|
||||
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
|
||||
};
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use std::io::{self, Read};
|
||||
fn main() {
|
||||
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
|
||||
let request=serde_json::from_str(&input).unwrap();
|
||||
match fidc_core::market_event_context::aggregate(request) {
|
||||
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
|
||||
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::io::Read;
|
||||
fn main() {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_to_string(&mut input).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
|
||||
let spec: fidc_core::daily_patterns::PatternSpec =
|
||||
serde_json::from_value(value["spec"].clone()).unwrap();
|
||||
let bars: Vec<fidc_core::session_events::MinuteBar> =
|
||||
serde_json::from_value(value["bars"].clone()).unwrap();
|
||||
let result = fidc_core::session_events::evaluate(
|
||||
&spec.validate().unwrap(),
|
||||
value["symbol"].as_str().unwrap(),
|
||||
&bars,
|
||||
serde_json::from_value(value["decision_at"].clone()).unwrap(),
|
||||
);
|
||||
match result {
|
||||
Ok(row) => println!(
|
||||
"{}",
|
||||
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+774
-33
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1641
-250
File diff suppressed because it is too large
Load Diff
+191
-85
@@ -1,4 +1,5 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -16,7 +17,7 @@ use crate::futures::{
|
||||
FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect,
|
||||
FuturesTransactionCostModel,
|
||||
};
|
||||
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics};
|
||||
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
||||
use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope};
|
||||
use crate::rules::EquityRuleHooks;
|
||||
@@ -90,6 +91,9 @@ impl Default for ProcessEventRetention {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DailyEquityPoint {
|
||||
/// Close-of-signal-day cash baseline before lagged trading begins.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub signal_baseline: bool,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub cash: f64,
|
||||
@@ -108,6 +112,14 @@ pub struct DailyEquityPoint {
|
||||
pub diagnostics: String,
|
||||
}
|
||||
|
||||
impl DailyEquityPoint {
|
||||
pub fn benchmark_reference_close(&self) -> f64 {
|
||||
if self.signal_baseline { self.benchmark_close }
|
||||
else if self.benchmark_prev_close.is_finite() && self.benchmark_prev_close > f64::EPSILON { self.benchmark_prev_close }
|
||||
else { self.benchmark_close }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacktestResult {
|
||||
pub strategy_name: String,
|
||||
@@ -333,7 +345,7 @@ impl BacktestResult {
|
||||
let mut previous_benchmark = self
|
||||
.equity_curve
|
||||
.first()
|
||||
.map(|point| point.benchmark_prev_close)
|
||||
.map(DailyEquityPoint::benchmark_reference_close)
|
||||
.unwrap_or_default();
|
||||
for point in &self.equity_curve {
|
||||
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
||||
@@ -453,8 +465,64 @@ pub struct BacktestEngine<S, C, R> {
|
||||
futures_cost_model: FuturesTransactionCostModel,
|
||||
futures_validation_config: FuturesValidationConfig,
|
||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||
execution_quote_request_cache:
|
||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||
}
|
||||
|
||||
fn backtest_execution_schedule(
|
||||
data: &DataSet,
|
||||
start_date: Option<NaiveDate>,
|
||||
end_date: Option<NaiveDate>,
|
||||
decision_lag_trading_days: usize,
|
||||
) -> Vec<(NaiveDate, Option<(usize, NaiveDate)>)> {
|
||||
let calendar_dates = data
|
||||
.calendar()
|
||||
.iter()
|
||||
.filter(|date| start_date.map(|start| *date >= start).unwrap_or(true))
|
||||
.filter(|date| end_date.map(|end| *date <= end).unwrap_or(true))
|
||||
.collect::<Vec<_>>();
|
||||
let has_decision_inputs = |date: NaiveDate| {
|
||||
!data.factor_snapshot_rows_on(date).is_empty()
|
||||
&& !data.candidate_snapshot_rows_on(date).is_empty()
|
||||
};
|
||||
let has_execution_market = |date: NaiveDate| !data.market_snapshot_rows_on(date).is_empty();
|
||||
let mut schedule = Vec::new();
|
||||
for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() {
|
||||
if decision_lag_trading_days == 0 {
|
||||
if has_decision_inputs(execution_date) {
|
||||
schedule.push((execution_date, Some((calendar_idx, execution_date))));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_execution_market(execution_date) {
|
||||
continue;
|
||||
}
|
||||
let decision_slot = calendar_idx
|
||||
.checked_sub(decision_lag_trading_days)
|
||||
.map(|decision_idx| (decision_idx, calendar_dates[decision_idx]));
|
||||
match decision_slot {
|
||||
Some((_, decision_date)) if has_decision_inputs(decision_date) => {
|
||||
schedule.push((execution_date, decision_slot));
|
||||
}
|
||||
None => schedule.push((execution_date, None)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
schedule
|
||||
}
|
||||
|
||||
pub fn backtest_execution_dates(
|
||||
data: &DataSet,
|
||||
start_date: Option<NaiveDate>,
|
||||
end_date: Option<NaiveDate>,
|
||||
decision_lag_trading_days: usize,
|
||||
) -> Vec<NaiveDate> {
|
||||
backtest_execution_schedule(data, start_date, end_date, decision_lag_trading_days)
|
||||
.into_iter()
|
||||
.map(|(execution_date, _)| execution_date)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
@@ -484,10 +552,17 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
futures_cost_model: FuturesTransactionCostModel::default(),
|
||||
futures_validation_config: FuturesValidationConfig::default(),
|
||||
execution_quote_loader: None,
|
||||
preplanned_decision_quote_symbols_by_date: None,
|
||||
execution_quote_request_cache: BTreeSet::new(),
|
||||
risk_free_rate_contract: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_risk_free_rate_contract(mut self, contract: RiskFreeRateContract) -> Self {
|
||||
self.risk_free_rate_contract = Some(contract);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn into_data(self) -> DataSet {
|
||||
self.data
|
||||
}
|
||||
@@ -502,6 +577,14 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
||||
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 {
|
||||
self.dividend_reinvestment = enabled;
|
||||
self
|
||||
@@ -1970,52 +2053,16 @@ where
|
||||
self.subscriptions = self.strategy.initial_subscriptions();
|
||||
let scheduler_calendar = self.data.calendar().clone();
|
||||
let scheduler = Scheduler::new(&scheduler_calendar);
|
||||
let calendar_dates = self
|
||||
.data
|
||||
.calendar()
|
||||
let execution_schedule = backtest_execution_schedule(
|
||||
&self.data,
|
||||
self.config.start_date,
|
||||
self.config.end_date,
|
||||
self.config.decision_lag_trading_days,
|
||||
);
|
||||
let execution_dates = execution_schedule
|
||||
.iter()
|
||||
.filter(|date| {
|
||||
self.config
|
||||
.start_date
|
||||
.map(|start| *date >= start)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true))
|
||||
.map(|(execution_date, _)| *execution_date)
|
||||
.collect::<Vec<_>>();
|
||||
let has_decision_inputs = |date: NaiveDate| {
|
||||
!self.data.factor_snapshot_rows_on(date).is_empty()
|
||||
&& !self.data.candidate_snapshot_rows_on(date).is_empty()
|
||||
};
|
||||
let has_execution_market =
|
||||
|date: NaiveDate| !self.data.market_snapshot_rows_on(date).is_empty();
|
||||
let mut execution_dates = Vec::new();
|
||||
let mut decision_slots = Vec::new();
|
||||
for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() {
|
||||
if self.config.decision_lag_trading_days == 0 {
|
||||
if has_decision_inputs(execution_date) {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(Some((calendar_idx, execution_date)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !has_execution_market(execution_date) {
|
||||
continue;
|
||||
}
|
||||
let decision_slot = calendar_idx
|
||||
.checked_sub(self.config.decision_lag_trading_days)
|
||||
.map(|decision_idx| (decision_idx, calendar_dates[decision_idx]));
|
||||
match decision_slot {
|
||||
Some((_, decision_date)) if has_decision_inputs(decision_date) => {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(decision_slot);
|
||||
}
|
||||
None => {
|
||||
execution_dates.push(execution_date);
|
||||
decision_slots.push(None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut result = BacktestResult {
|
||||
strategy_name: self.strategy.name().to_string(),
|
||||
benchmark_series: self
|
||||
@@ -2110,7 +2157,9 @@ where
|
||||
let day_order_start = result.order_events.len();
|
||||
let day_fill_start = result.fills.len();
|
||||
|
||||
let decision_slot = decision_slots.get(execution_idx).copied().flatten();
|
||||
let decision_slot = execution_schedule
|
||||
.get(execution_idx)
|
||||
.and_then(|(_, decision_slot)| *decision_slot);
|
||||
let Some((decision_index, decision_date)) = decision_slot else {
|
||||
let mut process_events = Vec::new();
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
@@ -2164,6 +2213,7 @@ where
|
||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
signal_baseline: true,
|
||||
date: execution_date,
|
||||
cash: aggregate_cash,
|
||||
market_value: aggregate_market_value,
|
||||
@@ -2226,8 +2276,7 @@ where
|
||||
let coarse_schedule_rules = schedule_rules
|
||||
.iter()
|
||||
.filter(|rule| {
|
||||
keep_timed_rules_on_coarse_stage
|
||||
|| !is_on_day_or_bar_physical_time_rule(rule)
|
||||
keep_timed_rules_on_coarse_stage || !is_on_day_or_bar_physical_time_rule(rule)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
@@ -2493,32 +2542,47 @@ where
|
||||
)?;
|
||||
let on_day_open_orders = self.open_order_views();
|
||||
let decision_quote_times = self.strategy.decision_quote_times();
|
||||
if !decision_quote_times.is_empty() {
|
||||
let decision_quote_symbols =
|
||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||
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_date,
|
||||
decision_index,
|
||||
data: &self.data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: self.futures_account.as_ref(),
|
||||
open_orders: &on_day_open_orders,
|
||||
dynamic_universe: self.dynamic_universe.as_ref(),
|
||||
subscriptions: &self.subscriptions,
|
||||
process_events: &process_events,
|
||||
active_process_event: None,
|
||||
active_datetime: stage_datetime(
|
||||
decision_quote_symbols,
|
||||
&decision_quote_times,
|
||||
)?;
|
||||
} else {
|
||||
let decision_quote_symbols =
|
||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||
execution_date,
|
||||
decision_date,
|
||||
default_stage_time(ScheduleStage::OnDay),
|
||||
),
|
||||
order_events: result.order_events.as_slice(),
|
||||
fills: result.fills.as_slice(),
|
||||
})?;
|
||||
self.ensure_execution_quotes_for_symbols_at_times(
|
||||
execution_date,
|
||||
&decision_quote_symbols,
|
||||
&decision_quote_times,
|
||||
)?;
|
||||
decision_index,
|
||||
data: &self.data,
|
||||
portfolio: &portfolio,
|
||||
futures_account: self.futures_account.as_ref(),
|
||||
open_orders: &on_day_open_orders,
|
||||
dynamic_universe: self.dynamic_universe.as_ref(),
|
||||
subscriptions: &self.subscriptions,
|
||||
process_events: &process_events,
|
||||
active_process_event: None,
|
||||
active_datetime: stage_datetime(
|
||||
decision_date,
|
||||
default_stage_time(ScheduleStage::OnDay),
|
||||
),
|
||||
order_events: result.order_events.as_slice(),
|
||||
fills: result.fills.as_slice(),
|
||||
})?;
|
||||
self.ensure_execution_quotes_for_symbols_at_times(
|
||||
execution_date,
|
||||
&decision_quote_symbols,
|
||||
&decision_quote_times,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
self.ensure_execution_quotes_for_portfolio_times(
|
||||
execution_date,
|
||||
@@ -2774,9 +2838,7 @@ where
|
||||
]);
|
||||
let minute_all_time_rules = intraday_schedule_rules
|
||||
.iter()
|
||||
.filter(|rule| {
|
||||
rule.stage == ScheduleStage::Minute && rule.time_rule.is_none()
|
||||
})
|
||||
.filter(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let minute_schedule_all_times = !minute_all_time_rules.is_empty();
|
||||
@@ -2798,10 +2860,9 @@ where
|
||||
loop {
|
||||
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
|
||||
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
|
||||
let Some(minute_timestamp) = next_minute_event_timestamp(
|
||||
next_quote_timestamp,
|
||||
next_schedule_timestamp,
|
||||
) else {
|
||||
let Some(minute_timestamp) =
|
||||
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let minute_time = minute_timestamp.time();
|
||||
@@ -2816,8 +2877,7 @@ where
|
||||
.expect("peeked minute quote must be available"),
|
||||
);
|
||||
}
|
||||
let has_specific_schedule =
|
||||
next_schedule_timestamp == Some(minute_timestamp);
|
||||
let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp);
|
||||
if has_specific_schedule {
|
||||
minute_schedule_timestamps.next();
|
||||
}
|
||||
@@ -2975,6 +3035,13 @@ where
|
||||
&mut minute_report.process_events,
|
||||
)?;
|
||||
merge_broker_report(&mut report, minute_report);
|
||||
decision.notes.append(&mut minute_decision.notes);
|
||||
decision
|
||||
.diagnostics
|
||||
.append(&mut minute_decision.diagnostics);
|
||||
decision
|
||||
.risk_decisions
|
||||
.append(&mut minute_decision.risk_decisions);
|
||||
publish_phase_event(
|
||||
&mut self.strategy,
|
||||
&mut self.process_event_bus,
|
||||
@@ -2996,7 +3063,7 @@ where
|
||||
drop(minute_group);
|
||||
drop(minute_quotes);
|
||||
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(
|
||||
@@ -3314,6 +3381,7 @@ where
|
||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: execution_date,
|
||||
cash: aggregate_cash,
|
||||
market_value: aggregate_market_value,
|
||||
@@ -3374,7 +3442,9 @@ where
|
||||
&result.daily_holdings,
|
||||
&result.account_events,
|
||||
self.aggregate_initial_cash(),
|
||||
);
|
||||
self.risk_free_rate_contract.as_ref(),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -4901,6 +4971,8 @@ mod tests {
|
||||
quantity: 100,
|
||||
reason: rule.name.clone(),
|
||||
}],
|
||||
notes: vec![format!("note:{}", rule.name)],
|
||||
diagnostics: vec![format!("diagnostic:{}", rule.name)],
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
@@ -5293,6 +5365,7 @@ mod tests {
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -5448,6 +5521,28 @@ mod tests {
|
||||
.expect("dataset")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backtest_execution_dates_match_sparse_lagged_equity_schedule() {
|
||||
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6), d(2025, 1, 7)];
|
||||
let data = DataSet::from_components(
|
||||
vec![default_instrument()],
|
||||
dates.iter().map(|date| market(*date, 10.0, 10.0)).collect(),
|
||||
vec![factor(dates[0]), factor(dates[2])],
|
||||
vec![candidate(dates[0]), candidate(dates[2])],
|
||||
dates.iter().map(|date| benchmark(*date)).collect(),
|
||||
)
|
||||
.expect("sparse lagged dataset");
|
||||
|
||||
assert_eq!(
|
||||
super::backtest_execution_dates(&data, Some(dates[0]), Some(dates[3]), 1,),
|
||||
vec![dates[0], dates[1], dates[3]]
|
||||
);
|
||||
assert_eq!(
|
||||
super::backtest_execution_dates(&data, Some(dates[0]), Some(dates[3]), 0,),
|
||||
vec![dates[0], dates[2]]
|
||||
);
|
||||
}
|
||||
|
||||
fn engine_with_matching(
|
||||
matching_type: MatchingType,
|
||||
execution_price_field: PriceField,
|
||||
@@ -5649,10 +5744,21 @@ mod tests {
|
||||
result.fills[0].execution_start_timestamp,
|
||||
date.and_hms_opt(10, 18, 0)
|
||||
);
|
||||
assert_eq!(result.fills[1].execution_timestamp, date.and_hms_opt(15, 10, 0));
|
||||
assert_eq!(
|
||||
result.fills[1].execution_timestamp,
|
||||
date.and_hms_opt(15, 10, 0)
|
||||
);
|
||||
assert_eq!(result.fills[1].price, 10.0);
|
||||
assert_eq!(result.fills[0].reason, "morning");
|
||||
assert_eq!(result.fills[1].reason, "post_close");
|
||||
assert_eq!(
|
||||
result.equity_curve[0].notes,
|
||||
"note:morning | note:post_close"
|
||||
);
|
||||
assert_eq!(
|
||||
result.equity_curve[0].diagnostics,
|
||||
"diagnostic:morning | diagnostic:post_close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Cross-sectional operators require an explicit complete universe, never a UI page.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const OPERATORS: &[&str] = &[
|
||||
"RANK",
|
||||
"PERCENTILE",
|
||||
"TOP",
|
||||
"BOTTOM",
|
||||
"TOP_PERCENT",
|
||||
"BOTTOM_PERCENT",
|
||||
"WINSORIZE",
|
||||
"INDUSTRY_NEUTRALIZE",
|
||||
"SIZE_NEUTRALIZE",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub symbol: String,
|
||||
pub value: f64,
|
||||
pub industry: Option<String>,
|
||||
pub market_cap: Option<f64>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Output {
|
||||
pub symbol: String,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
|
||||
pub fn rank_history(
|
||||
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use serde_json::json;
|
||||
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|
||||
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|
||||
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|
||||
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|
||||
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
|
||||
return Err("research_rank_history_incomplete_or_invalid_universe".into());
|
||||
}
|
||||
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
|
||||
let mut percentile = rank.clone();
|
||||
let mut unknown_dates = Vec::new();
|
||||
for (i, date) in dates.iter().enumerate() {
|
||||
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
|
||||
continue;
|
||||
}
|
||||
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
|
||||
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||
}
|
||||
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
|
||||
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
|
||||
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
|
||||
}
|
||||
|
||||
fn mean(values: &[f64]) -> f64 {
|
||||
let base = values[0];
|
||||
base + values
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|v| (v - base) / values.len() as f64)
|
||||
.sum::<f64>()
|
||||
}
|
||||
fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||||
let x = p * (sorted.len() - 1) as f64;
|
||||
let l = x.floor() as usize;
|
||||
let r = x.ceil() as usize;
|
||||
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
name: &str,
|
||||
universe: &[String],
|
||||
rows: &[Observation],
|
||||
threshold: f64,
|
||||
) -> Result<Vec<Output>, String> {
|
||||
let expected = universe.iter().collect::<BTreeSet<_>>();
|
||||
if rows.is_empty()
|
||||
|| rows.len() > 20_000
|
||||
|| expected.len() != universe.len()
|
||||
|| rows.len() != universe.len()
|
||||
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|
||||
|| rows.iter().any(|r| !r.value.is_finite())
|
||||
{
|
||||
return Err("cross_section_incomplete_or_invalid_universe".into());
|
||||
}
|
||||
if !OPERATORS.contains(&name) || !threshold.is_finite() {
|
||||
return Err("cross_section_operator_invalid".into());
|
||||
}
|
||||
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|
||||
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|
||||
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
|
||||
{
|
||||
return Err("cross_section_threshold_invalid".into());
|
||||
}
|
||||
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
|
||||
if name == "INDUSTRY_NEUTRALIZE" {
|
||||
for row in rows {
|
||||
let industry = row
|
||||
.industry
|
||||
.as_deref()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or("cross_section_pit_industry_missing")?;
|
||||
industry_values.entry(industry).or_default().push(row.value);
|
||||
}
|
||||
}
|
||||
let size = if name == "SIZE_NEUTRALIZE" {
|
||||
let x = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.market_cap
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(f64::ln)
|
||||
.ok_or("cross_section_market_cap_missing")
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let xm = mean(&x);
|
||||
let ym = mean(&sorted);
|
||||
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
|
||||
if variance == 0.0 || rows.len() < 3 {
|
||||
return Err("cross_section_size_regression_unidentified".into());
|
||||
}
|
||||
let beta = x
|
||||
.iter()
|
||||
.zip(rows)
|
||||
.map(|(x, y)| (x - xm) * (y.value - ym))
|
||||
.sum::<f64>()
|
||||
/ variance;
|
||||
Some((x, xm, ym, beta))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| {
|
||||
let low = sorted.partition_point(|v| *v < row.value);
|
||||
let high = sorted.partition_point(|v| *v <= row.value);
|
||||
let rank = (low + 1 + high) as f64 / 2.0;
|
||||
let descending = (rows.len() + 1) as f64 - rank;
|
||||
let percentile = if rows.len() == 1 {
|
||||
0.5
|
||||
} else {
|
||||
(rank - 1.0) / (rows.len() - 1) as f64
|
||||
};
|
||||
let value = match name {
|
||||
"RANK" => descending,
|
||||
"PERCENTILE" => percentile,
|
||||
"TOP" => f64::from(descending <= threshold),
|
||||
"BOTTOM" => f64::from(rank <= threshold),
|
||||
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
|
||||
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
|
||||
"WINSORIZE" => row.value.clamp(
|
||||
quantile(&sorted, threshold),
|
||||
quantile(&sorted, 1.0 - threshold),
|
||||
),
|
||||
"INDUSTRY_NEUTRALIZE" => {
|
||||
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
|
||||
}
|
||||
"SIZE_NEUTRALIZE" => {
|
||||
let (x, xm, ym, beta) = size.as_ref().unwrap();
|
||||
row.value - (ym + beta * (x[index] - xm))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !value.is_finite() {
|
||||
return Err("cross_section_result_nonfinite".into());
|
||||
}
|
||||
Ok(Output {
|
||||
symbol: row.symbol.clone(),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
|
||||
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
|
||||
let universe=vec!["A".into(),"B".into(),"C".into()];
|
||||
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
|
||||
let out=rank_history(&dates,&universe,&values).unwrap();
|
||||
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
|
||||
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
|
||||
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
|
||||
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
|
||||
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
|
||||
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
|
||||
assert!(rank_history(&dates,&universe[..2],&values).is_err());
|
||||
}
|
||||
fn rows() -> Vec<Observation> {
|
||||
[1.0, 3.0, 3.0, 4.0]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &value)| Observation {
|
||||
symbol: format!("S{i}"),
|
||||
value,
|
||||
industry: Some(if i < 2 { "A" } else { "B" }.into()),
|
||||
market_cap: Some(10.0 + i as f64),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn ties_keep_equal_rank_and_missing_universe_rejects() {
|
||||
let r = rows();
|
||||
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||
vec![4.0, 2.5, 2.5, 1.0]
|
||||
);
|
||||
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn neutralization_preserves_input_order() {
|
||||
let r = rows();
|
||||
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||
vec![-1.0, 1.0, -0.5, 0.5]
|
||||
);
|
||||
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,17 @@
|
||||
use chrono::NaiveDate;
|
||||
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)]
|
||||
pub struct Instrument {
|
||||
pub symbol: String,
|
||||
@@ -21,17 +32,29 @@ impl Instrument {
|
||||
}
|
||||
|
||||
pub fn minimum_order_quantity(&self) -> u32 {
|
||||
match self.board.trim().to_ascii_uppercase().as_str() {
|
||||
"KSH" => 200,
|
||||
"BJS" | "BJ" | "BJSE" => 100,
|
||||
_ => self.effective_round_lot(),
|
||||
let board = self.board.trim();
|
||||
if board.eq_ignore_ascii_case("KSH") {
|
||||
200
|
||||
} else if board.eq_ignore_ascii_case("BJS")
|
||||
|| board.eq_ignore_ascii_case("BJ")
|
||||
|| board.eq_ignore_ascii_case("BJSE")
|
||||
{
|
||||
100
|
||||
} else {
|
||||
self.effective_round_lot()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order_step_size(&self) -> u32 {
|
||||
match self.board.trim().to_ascii_uppercase().as_str() {
|
||||
"KSH" | "BJS" | "BJ" | "BJSE" => 1,
|
||||
_ => self.effective_round_lot(),
|
||||
let board = self.board.trim();
|
||||
if board.eq_ignore_ascii_case("KSH")
|
||||
|| board.eq_ignore_ascii_case("BJS")
|
||||
|| board.eq_ignore_ascii_case("BJ")
|
||||
|| board.eq_ignore_ascii_case("BJSE")
|
||||
{
|
||||
1
|
||||
} else {
|
||||
self.effective_round_lot()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +79,50 @@ fn default_status() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 {
|
||||
Instrument {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
name: "test".to_string(),
|
||||
board: board.to_string(),
|
||||
round_lot,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
|
||||
let kcb = instrument(" kSh ", 100);
|
||||
assert_eq!(kcb.minimum_order_quantity(), 200);
|
||||
assert_eq!(kcb.order_step_size(), 1);
|
||||
|
||||
let bjse = instrument("bjse", 100);
|
||||
assert_eq!(bjse.minimum_order_quantity(), 100);
|
||||
assert_eq!(bjse.order_step_size(), 1);
|
||||
|
||||
let main_board = instrument("SZSE", 50);
|
||||
assert_eq!(main_board.minimum_order_quantity(), 50);
|
||||
assert_eq!(main_board.order_step_size(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
+21
-11
@@ -2,6 +2,12 @@ pub mod broker;
|
||||
pub mod calendar;
|
||||
pub mod cost;
|
||||
pub mod data;
|
||||
pub mod daily_patterns;
|
||||
pub mod pattern_context;
|
||||
pub mod session_events;
|
||||
pub mod factor_events;
|
||||
pub mod factor_cross_section;
|
||||
pub mod market_event_context;
|
||||
pub mod engine;
|
||||
pub mod event_bus;
|
||||
pub mod events;
|
||||
@@ -14,6 +20,7 @@ pub mod platform_expr_strategy;
|
||||
pub mod platform_runtime_schema;
|
||||
pub mod platform_strategy_spec;
|
||||
pub mod portfolio;
|
||||
pub mod portfolio_loss;
|
||||
pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
@@ -31,15 +38,15 @@ pub use data::{
|
||||
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
||||
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
||||
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
||||
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord,
|
||||
SplitRecord, YieldCurvePoint,
|
||||
IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar,
|
||||
PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
|
||||
};
|
||||
pub use engine::{
|
||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
|
||||
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
||||
ProcessEventRetention,
|
||||
ProcessEventRetention, backtest_execution_dates,
|
||||
};
|
||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||
pub use events::{
|
||||
@@ -56,13 +63,15 @@ pub use futures::{
|
||||
FuturesTradingParameter, FuturesTransactionCostModel,
|
||||
};
|
||||
pub use instrument::Instrument;
|
||||
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
pub use metrics::{
|
||||
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, compute_backtest_metrics,
|
||||
};
|
||||
pub use platform_expr_strategy::{
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||
PlatformUniverseActionKind,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||
PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode,
|
||||
PlatformTradeAction, PlatformUniverseActionKind,
|
||||
};
|
||||
pub use platform_runtime_schema::{
|
||||
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
||||
@@ -75,12 +84,13 @@ pub use platform_strategy_spec::{
|
||||
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
|
||||
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
|
||||
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
|
||||
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
|
||||
StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment,
|
||||
StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec,
|
||||
platform_expr_config_from_spec, platform_expr_config_from_value,
|
||||
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig, StrategyRebalanceSpec,
|
||||
StrategyRiskPolicySpec, StrategyRuntimeEnvironment, StrategyRuntimeExpressions,
|
||||
StrategyRuntimeSpec, StrategyUniverseSpec, platform_expr_config_from_spec,
|
||||
platform_expr_config_from_value, validate_strategy_risk_policy_fields,
|
||||
};
|
||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||
pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
|
||||
pub use risk_control::{
|
||||
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
||||
StaticRiskRuleConfig, TradingConstraintConfig,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Complete published daily cross sections, independent of trading candidates and accounts.
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_market_event_context_v1";
|
||||
pub fn implementation_sha256() -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(include_bytes!("market_event_context.rs")))
|
||||
}
|
||||
pub const COMMON_FIELDS: &[&str] = &[
|
||||
"market_breadth", "market_return", "market_limit_up_count", "market_limit_down_count",
|
||||
"market_limit_up_rate", "market_broken_limit_rate", "market_high_board", "market_profit_effect",
|
||||
];
|
||||
pub const INDUSTRY_FIELDS: &[&str] = &[
|
||||
"industry_close", "industry_return_20", "industry_breadth", "industry_rank", "industry_size",
|
||||
];
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub symbol: String,
|
||||
pub industry: Option<String>,
|
||||
pub close: Option<f64>,
|
||||
pub high: Option<f64>,
|
||||
pub previous_close: Option<f64>,
|
||||
pub upper_limit: Option<f64>,
|
||||
pub lower_limit: Option<f64>,
|
||||
pub no_limit: Option<bool>,
|
||||
pub paused: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Day {
|
||||
pub date: NaiveDate,
|
||||
pub universe: Vec<String>,
|
||||
pub rows: Vec<Observation>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct State {
|
||||
pub last_date: Option<NaiveDate>,
|
||||
pub streaks: BTreeMap<String, Option<u32>>,
|
||||
pub limit_ups: BTreeSet<String>,
|
||||
pub industry_history: BTreeMap<String, Vec<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub days: Vec<Day>,
|
||||
#[serde(default)]
|
||||
pub previous: State,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OutputDay {
|
||||
pub date: NaiveDate,
|
||||
pub common: BTreeMap<String, Option<f64>>,
|
||||
pub industries: BTreeMap<String, BTreeMap<String, Option<f64>>>,
|
||||
pub members: BTreeMap<String, Option<String>>,
|
||||
pub securities: usize,
|
||||
pub active: usize,
|
||||
pub paused: usize,
|
||||
pub no_limit: usize,
|
||||
pub profit_effect_members: Vec<String>,
|
||||
pub profit_effect_missing: Vec<String>,
|
||||
pub industry_missing: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Output {
|
||||
pub contract: &'static str,
|
||||
pub days: Vec<OutputDay>,
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
fn positive(value: Option<f64>, symbol: &str, field: &str) -> Result<f64, String> {
|
||||
value.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.ok_or_else(|| format!("market_event_input_invalid: {symbol} {field}"))
|
||||
}
|
||||
fn average(values: impl Iterator<Item = f64>, n: usize) -> f64 {
|
||||
values.map(|v| v / n as f64).sum()
|
||||
}
|
||||
|
||||
pub fn aggregate(request: Request) -> Result<Output, String> {
|
||||
let mut state = request.previous;
|
||||
if request.days.is_empty() || request.days.len() > 30
|
||||
|| request.days.iter().map(|d| d.rows.len()).sum::<usize>() > 60_000
|
||||
|| state.streaks.len() > 20_000 || state.limit_ups.len() > 20_000
|
||||
|| state.industry_history.len() > 2000
|
||||
|| state.industry_history.values().any(|v| v.is_empty() || v.len() > 21
|
||||
|| v.iter().any(|x| !x.is_finite() || *x <= 0.0))
|
||||
|| state.last_date.is_none() && (!state.streaks.is_empty() || !state.limit_ups.is_empty() || !state.industry_history.is_empty()) {
|
||||
return Err("market_event_history_budget_or_state_invalid".into());
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for day in request.days {
|
||||
if state.last_date.is_some_and(|d| d >= day.date)
|
||||
|| day.universe.is_empty() || day.universe.len() > 20_000
|
||||
|| day.universe.iter().collect::<BTreeSet<_>>().len() != day.universe.len()
|
||||
|| day.rows.len() != day.universe.len()
|
||||
|| day.rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != day.universe.iter().collect::<BTreeSet<_>>() {
|
||||
return Err(format!("market_event_incomplete_cross_section: {}", day.date));
|
||||
}
|
||||
let mut returns = BTreeMap::new();
|
||||
let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
|
||||
let mut members = BTreeMap::new();
|
||||
let mut streaks = BTreeMap::new();
|
||||
let mut ups = BTreeSet::new();
|
||||
let mut downs = 0; let mut touched = 0; let mut broken = 0; let mut paused = 0; let mut unlimited = 0;
|
||||
for row in &day.rows {
|
||||
let industry = row.industry.clone().filter(|s| !s.trim().is_empty());
|
||||
members.insert(row.symbol.clone(), industry.clone());
|
||||
match row.paused {
|
||||
Some(true) => {
|
||||
paused += 1;
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten());
|
||||
continue;
|
||||
},
|
||||
Some(false) => {},
|
||||
None => return Err(format!("market_event_pause_state_missing: {} {}", day.date, row.symbol)),
|
||||
}
|
||||
let c = positive(row.close, &row.symbol, "close")?;
|
||||
let h = positive(row.high, &row.symbol, "high")?;
|
||||
let p = positive(row.previous_close, &row.symbol, "previous_close")?;
|
||||
if h + 1e-8 < c { return Err(format!("market_event_high_below_close: {}", row.symbol)); }
|
||||
let change = c / p - 1.0;
|
||||
returns.insert(row.symbol.clone(), change);
|
||||
if let Some(industry) = industry { groups.entry(industry).or_default().push(change); }
|
||||
let is_up = match row.no_limit {
|
||||
Some(true) => { unlimited += 1; false },
|
||||
Some(false) => {
|
||||
let upper = positive(row.upper_limit, &row.symbol, "upper_limit")?;
|
||||
let lower = positive(row.lower_limit, &row.symbol, "lower_limit")?;
|
||||
if lower >= upper || c > upper + 1e-8 || c < lower - 1e-8 {
|
||||
return Err(format!("market_event_limit_bounds_invalid: {} {}", day.date, row.symbol));
|
||||
}
|
||||
let at_up = (c - upper).abs() <= 1e-8;
|
||||
if (c - lower).abs() <= 1e-8 { downs += 1; }
|
||||
if h >= upper - 1e-8 { touched += 1; if !at_up { broken += 1; } }
|
||||
at_up
|
||||
},
|
||||
None => return Err(format!("market_event_limit_policy_missing: {}", row.symbol)),
|
||||
};
|
||||
if is_up {
|
||||
ups.insert(row.symbol.clone());
|
||||
// The first observed limit-up may already be a continuing streak.
|
||||
streaks.insert(row.symbol.clone(), state.streaks.get(&row.symbol).copied().flatten().map(|v| v + 1));
|
||||
} else { streaks.insert(row.symbol.clone(), Some(0)); }
|
||||
}
|
||||
let active = returns.len();
|
||||
if active == 0 { return Err(format!("market_event_no_active_market: {}", day.date)); }
|
||||
let previous_ups = state.limit_ups.iter().cloned().collect::<Vec<_>>();
|
||||
let profit_missing = previous_ups.iter().filter(|s| !returns.contains_key(*s)).cloned().collect::<Vec<_>>();
|
||||
let profit = if previous_ups.is_empty() || !profit_missing.is_empty() { None }
|
||||
else { Some(average(previous_ups.iter().map(|s| returns[s]), previous_ups.len())) };
|
||||
let board = if ups.iter().any(|s| streaks[s].is_none()) { None }
|
||||
else { Some(ups.iter().map(|s| streaks[s].unwrap()).max().unwrap_or(0) as f64) };
|
||||
let common = BTreeMap::from([
|
||||
("market_breadth".into(), Some(returns.values().filter(|v| **v > 0.0).count() as f64 / active as f64)),
|
||||
("market_return".into(), Some(average(returns.values().copied(), active))),
|
||||
("market_limit_up_count".into(), Some(ups.len() as f64)),
|
||||
("market_limit_down_count".into(), Some(downs as f64)),
|
||||
("market_limit_up_rate".into(), (touched > 0).then(|| ups.len() as f64 / touched as f64)),
|
||||
("market_broken_limit_rate".into(), (touched > 0).then(|| broken as f64 / touched as f64)),
|
||||
("market_high_board".into(), board),
|
||||
("market_profit_effect".into(), profit),
|
||||
]);
|
||||
let mut industries = BTreeMap::new();
|
||||
// A disappeared group breaks its continuous history; no stale NAV is carried forward.
|
||||
state.industry_history.retain(|key, _| groups.contains_key(key));
|
||||
for (industry, values) in groups {
|
||||
let history = state.industry_history.entry(industry.clone()).or_default();
|
||||
let nav = history.last().copied().unwrap_or(1.0) * (1.0 + average(values.iter().copied(), values.len()));
|
||||
history.push(nav);
|
||||
if history.len() > 21 { history.remove(0); }
|
||||
let momentum = (history.len() == 21).then(|| nav / history[0] - 1.0);
|
||||
industries.insert(industry, BTreeMap::from([
|
||||
("industry_close".into(), Some(nav)), ("industry_return_20".into(), momentum),
|
||||
("industry_breadth".into(), Some(values.iter().filter(|v| **v > 0.0).count() as f64 / values.len() as f64)),
|
||||
]));
|
||||
}
|
||||
let universe = industries.keys().cloned().collect::<Vec<_>>();
|
||||
let known = industries.values().all(|g| g["industry_return_20"].is_some());
|
||||
let ranks = if known && !universe.is_empty() {
|
||||
crate::factor_cross_section::evaluate("RANK", &universe, &industries.iter().map(|(s,g)|
|
||||
crate::factor_cross_section::Observation {symbol:s.clone(), value:g["industry_return_20"].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>(),0.0)?
|
||||
.into_iter().map(|r|(r.symbol,r.value)).collect::<BTreeMap<_,_>>()
|
||||
} else { BTreeMap::new() };
|
||||
for (name, fields) in &mut industries {
|
||||
fields.insert("industry_rank".into(), ranks.get(name).copied());
|
||||
fields.insert("industry_size".into(), Some(universe.len() as f64));
|
||||
}
|
||||
let industry_missing=members.iter().filter(|(_,group)|group.is_none()).map(|(s,_)|s.clone()).collect::<Vec<_>>();
|
||||
if !industry_missing.is_empty() {
|
||||
// An unclassified member may belong to any group; never silently shrink a group.
|
||||
state.industry_history.clear();
|
||||
for fields in industries.values_mut() { for value in fields.values_mut() { *value=None; } }
|
||||
}
|
||||
output.push(OutputDay { date:day.date, common, industries, members, securities:day.rows.len(), active, paused,
|
||||
no_limit:unlimited, profit_effect_members:previous_ups, profit_effect_missing:profit_missing, industry_missing });
|
||||
state.last_date = Some(day.date); state.streaks = streaks; state.limit_ups = ups;
|
||||
}
|
||||
Ok(Output {contract:CONTRACT, days:output, state})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn day(n: u32, up: bool) -> Day {
|
||||
Day {date:NaiveDate::from_ymd_opt(2026,9,n).unwrap(), universe:vec!["A".into(),"B".into()], rows:vec![
|
||||
Observation{symbol:"A".into(),industry:Some("I".into()),close:Some(if up {11.0}else{10.0}),high:Some(11.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)},
|
||||
Observation{symbol:"B".into(),industry:Some("J".into()),close:Some(9.0),high:Some(10.0),previous_close:Some(10.0),upper_limit:Some(11.0),lower_limit:Some(9.0),no_limit:Some(false),paused:Some(false)}]}
|
||||
}
|
||||
#[test]
|
||||
fn formulas_use_real_limits_and_full_denominators() {
|
||||
let r=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
let d=&r.days[1];
|
||||
assert_eq!(d.common["market_breadth"],Some(0.5));
|
||||
assert_eq!(d.common["market_limit_down_count"],Some(1.0));
|
||||
assert_eq!(d.common["market_limit_up_rate"],Some(1.0));
|
||||
assert_eq!(r.days[0].common["market_limit_up_rate"],Some(0.0));
|
||||
assert_eq!(r.days[0].common["market_broken_limit_rate"],Some(1.0));
|
||||
assert_eq!(r.days[2].common["market_high_board"],Some(2.0));
|
||||
assert!((r.days[2].common["market_profit_effect"].unwrap()-0.1).abs()<1e-12);
|
||||
assert_eq!(r.days[0].common["market_profit_effect"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_duplicate_and_unproven_limit_states_fail() {
|
||||
let mut d=day(1,true);d.rows.pop();assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].upper_limit=None;assert!(aggregate(Request{days:vec![d],previous:State::default()}).is_err());
|
||||
let mut d=day(1,true);d.rows[0].no_limit=Some(true);d.rows[0].upper_limit=None;
|
||||
assert_eq!(aggregate(Request{days:vec![d],previous:State::default()}).unwrap().days[0].no_limit,1);
|
||||
}
|
||||
#[test]
|
||||
fn chunking_and_future_append_preserve_history() {
|
||||
let first=aggregate(Request{days:vec![day(1,false),day(2,true)],previous:State::default()}).unwrap();
|
||||
let next=aggregate(Request{days:vec![day(3,true)],previous:first.state}).unwrap();
|
||||
let full=aggregate(Request{days:vec![day(1,false),day(2,true),day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(serde_json::to_value(&first.days).unwrap(),serde_json::to_value(&full.days[..2]).unwrap());
|
||||
assert_eq!(serde_json::to_value(&next.days).unwrap(),serde_json::to_value(&full.days[2..]).unwrap());
|
||||
let unknown=aggregate(Request{days:vec![day(1,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(unknown.days[0].common["market_high_board"],None);
|
||||
}
|
||||
#[test]
|
||||
fn missing_industry_does_not_invent_groups_or_disable_independent_market_facts() {
|
||||
let mut missing=day(2,true);missing.rows[0].industry=None;
|
||||
let r=aggregate(Request{days:vec![day(1,false),missing,day(3,true)],previous:State::default()}).unwrap();
|
||||
assert_eq!(r.days[1].common["market_breadth"],Some(0.5));
|
||||
assert_eq!(r.days[1].industry_missing,vec!["A"]);
|
||||
assert!(r.days[1].industries.values().flat_map(|g|g.values()).all(Option::is_none));
|
||||
assert_eq!(r.days[2].industries["I"]["industry_return_20"],None);
|
||||
}
|
||||
}
|
||||
+303
-48
@@ -9,7 +9,28 @@ use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
const MONTHS_PER_YEAR: f64 = 12.0;
|
||||
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RiskFreeRateObservation {
|
||||
pub date: NaiveDate,
|
||||
pub source_date: NaiveDate,
|
||||
pub annual_rate: f64,
|
||||
pub daily_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RiskFreeRateContract {
|
||||
pub version: String,
|
||||
pub source: String,
|
||||
pub tenor: String,
|
||||
pub periods_per_year: f64,
|
||||
pub max_staleness_days: usize,
|
||||
pub observed_max_staleness_days: usize,
|
||||
pub sha256: String,
|
||||
pub observations: Vec<RiskFreeRateObservation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BacktestMetrics {
|
||||
@@ -29,6 +50,7 @@ pub struct BacktestMetrics {
|
||||
pub max_drawdown_duration_days: usize,
|
||||
pub total_trade_days: usize,
|
||||
pub sortino: f64,
|
||||
pub downside_risk: f64,
|
||||
pub information_ratio: f64,
|
||||
pub tracking_error: f64,
|
||||
pub volatility: f64,
|
||||
@@ -55,6 +77,13 @@ pub struct BacktestMetrics {
|
||||
pub excess_win_rate: f64,
|
||||
pub monthly_sharpe: f64,
|
||||
pub monthly_volatility: f64,
|
||||
pub risk_free_rate_contract_version: String,
|
||||
pub risk_free_rate_source: String,
|
||||
pub risk_free_rate_tenor: String,
|
||||
pub risk_free_rate_observation_count: usize,
|
||||
pub risk_free_rate_max_staleness_days: usize,
|
||||
pub risk_free_rate_observed_max_staleness_days: usize,
|
||||
pub risk_free_rate_sha256: String,
|
||||
}
|
||||
|
||||
pub fn compute_backtest_metrics(
|
||||
@@ -63,30 +92,23 @@ pub fn compute_backtest_metrics(
|
||||
daily_holdings: &[HoldingSummary],
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
) -> BacktestMetrics {
|
||||
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
return Ok(BacktestMetrics {
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
});
|
||||
};
|
||||
let Some(last_point) = equity_curve.last() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
return Ok(BacktestMetrics {
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let trade_days = equity_curve.len();
|
||||
let benchmark_start = if first_point.benchmark_prev_close.is_finite()
|
||||
&& first_point.benchmark_prev_close > f64::EPSILON
|
||||
{
|
||||
first_point.benchmark_prev_close
|
||||
} else {
|
||||
first_point.benchmark_close
|
||||
};
|
||||
let benchmark_start = first_point.benchmark_reference_close();
|
||||
let explicit_unit_nav = equity_curve.iter().any(|point| {
|
||||
point.external_cash_flow.abs() > f64::EPSILON
|
||||
|| (point.unit_nav.is_finite()
|
||||
@@ -122,6 +144,7 @@ pub fn compute_backtest_metrics(
|
||||
.zip(benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
let zero_risk_free_rates = vec![0.0; excess_returns.len()];
|
||||
|
||||
let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON {
|
||||
1.0
|
||||
@@ -140,16 +163,28 @@ pub fn compute_backtest_metrics(
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
|
||||
|
||||
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
|
||||
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
|
||||
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let (daily_risk_free_rates, risk_free_metadata) =
|
||||
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
|
||||
let risk_free_rate =
|
||||
effective_annual_risk_free_rate(&daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let sharpe = annualized_sharpe(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let sortino = annualized_sortino(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let downside_risk =
|
||||
annualized_downside_risk(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||
let information_ratio = annualized_sharpe(
|
||||
&excess_returns,
|
||||
&zero_risk_free_rates,
|
||||
TRADING_DAYS_PER_YEAR,
|
||||
);
|
||||
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
||||
let excess_sharpe = annualized_sharpe(
|
||||
&excess_returns,
|
||||
&zero_risk_free_rates,
|
||||
TRADING_DAYS_PER_YEAR,
|
||||
);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates);
|
||||
|
||||
let equity_nav = portfolio_nav;
|
||||
let benchmark_nav_series = equity_curve
|
||||
@@ -178,6 +213,8 @@ pub fn compute_backtest_metrics(
|
||||
.zip(monthly_benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
let monthly_risk_free_returns =
|
||||
group_monthly_risk_free_returns(equity_curve, &daily_risk_free_rates);
|
||||
let monthly_excess_win_rate = ratio(
|
||||
monthly_excess_returns
|
||||
.iter()
|
||||
@@ -187,7 +224,7 @@ pub fn compute_backtest_metrics(
|
||||
);
|
||||
let monthly_sharpe = annualized_sharpe(
|
||||
&monthly_portfolio_returns,
|
||||
risk_free_rate / MONTHS_PER_YEAR,
|
||||
&monthly_risk_free_returns,
|
||||
MONTHS_PER_YEAR,
|
||||
);
|
||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||
@@ -239,7 +276,7 @@ pub fn compute_backtest_metrics(
|
||||
|
||||
let total_trade_days = equity_by_date.len();
|
||||
|
||||
BacktestMetrics {
|
||||
Ok(BacktestMetrics {
|
||||
total_return,
|
||||
annual_return,
|
||||
sharpe,
|
||||
@@ -256,6 +293,7 @@ pub fn compute_backtest_metrics(
|
||||
max_drawdown_duration_days,
|
||||
total_trade_days,
|
||||
sortino,
|
||||
downside_risk,
|
||||
information_ratio,
|
||||
tracking_error,
|
||||
volatility,
|
||||
@@ -285,7 +323,14 @@ pub fn compute_backtest_metrics(
|
||||
excess_win_rate,
|
||||
monthly_sharpe,
|
||||
monthly_volatility,
|
||||
}
|
||||
risk_free_rate_contract_version: risk_free_metadata.version,
|
||||
risk_free_rate_source: risk_free_metadata.source,
|
||||
risk_free_rate_tenor: risk_free_metadata.tenor,
|
||||
risk_free_rate_observation_count: daily_risk_free_rates.len(),
|
||||
risk_free_rate_max_staleness_days: risk_free_metadata.max_staleness_days,
|
||||
risk_free_rate_observed_max_staleness_days: risk_free_metadata.observed_max_staleness_days,
|
||||
risk_free_rate_sha256: risk_free_metadata.sha256,
|
||||
})
|
||||
}
|
||||
|
||||
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
|
||||
@@ -316,13 +361,106 @@ fn annualize_return(total_return: f64, periods: usize) -> f64 {
|
||||
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
|
||||
}
|
||||
|
||||
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.len() < 2 {
|
||||
fn aligned_daily_risk_free_rates(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<(Vec<f64>, RiskFreeRateContract), String> {
|
||||
let Some(contract) = contract else {
|
||||
return Ok((
|
||||
vec![0.0; equity_curve.len()],
|
||||
RiskFreeRateContract {
|
||||
version: "not-configured".to_string(),
|
||||
source: "not-configured".to_string(),
|
||||
tenor: "NONE".to_string(),
|
||||
periods_per_year: TRADING_DAYS_PER_YEAR,
|
||||
..RiskFreeRateContract::default()
|
||||
},
|
||||
));
|
||||
};
|
||||
if contract.version.trim().is_empty()
|
||||
|| contract.source.trim().is_empty()
|
||||
|| contract.tenor.trim().is_empty()
|
||||
|| contract.sha256.len() != 64
|
||||
{
|
||||
return Err("risk-free rate contract metadata is incomplete".to_string());
|
||||
}
|
||||
if contract.observations.len() != equity_curve.len() {
|
||||
return Err(format!(
|
||||
"risk-free rate observation count mismatch: expected={} actual={}",
|
||||
equity_curve.len(),
|
||||
contract.observations.len()
|
||||
));
|
||||
}
|
||||
let mut rates = Vec::with_capacity(equity_curve.len());
|
||||
for (point, observation) in equity_curve.iter().zip(&contract.observations) {
|
||||
if observation.date != point.date {
|
||||
return Err(format!(
|
||||
"risk-free rate date mismatch: expected={} actual={}",
|
||||
point.date, observation.date
|
||||
));
|
||||
}
|
||||
if observation.source_date > observation.date {
|
||||
return Err(format!(
|
||||
"risk-free rate uses future observation: date={} source_date={}",
|
||||
observation.date, observation.source_date
|
||||
));
|
||||
}
|
||||
let staleness = observation
|
||||
.date
|
||||
.signed_duration_since(observation.source_date)
|
||||
.num_days();
|
||||
if staleness < 0 || staleness as usize > contract.max_staleness_days {
|
||||
return Err(format!(
|
||||
"risk-free rate observation is stale: date={} source_date={} days={}",
|
||||
observation.date, observation.source_date, staleness
|
||||
));
|
||||
}
|
||||
if !observation.annual_rate.is_finite()
|
||||
|| observation.annual_rate <= -1.0
|
||||
|| observation.annual_rate >= 1.0
|
||||
|| !observation.daily_rate.is_finite()
|
||||
|| observation.daily_rate <= -1.0
|
||||
{
|
||||
return Err(format!(
|
||||
"risk-free rate observation is invalid: date={}",
|
||||
observation.date
|
||||
));
|
||||
}
|
||||
let periods_per_year =
|
||||
if contract.periods_per_year.is_finite() && contract.periods_per_year > 0.0 {
|
||||
contract.periods_per_year
|
||||
} else {
|
||||
TRADING_DAYS_PER_YEAR
|
||||
};
|
||||
let expected_daily = (1.0 + observation.annual_rate).powf(1.0 / periods_per_year) - 1.0;
|
||||
if (expected_daily - observation.daily_rate).abs() > 1e-12 {
|
||||
return Err(format!(
|
||||
"risk-free daily conversion mismatch: date={} expected={} actual={}",
|
||||
observation.date, expected_daily, observation.daily_rate
|
||||
));
|
||||
}
|
||||
rates.push(observation.daily_rate);
|
||||
}
|
||||
Ok((rates, contract.clone()))
|
||||
}
|
||||
|
||||
fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -> f64 {
|
||||
if daily_rates.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean_log =
|
||||
daily_rates.iter().map(|rate| rate.ln_1p()).sum::<f64>() / daily_rates.len() as f64;
|
||||
(mean_log * periods_per_year).exp_m1()
|
||||
}
|
||||
|
||||
fn annualized_sharpe(returns: &[f64], daily_risk_free_rates: &[f64], periods_per_year: f64) -> f64 {
|
||||
if returns.len() < 2 || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let mean_ret = mean(&adjusted);
|
||||
let std = std_dev(&adjusted);
|
||||
@@ -333,23 +471,24 @@ fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.is_empty() {
|
||||
fn annualized_sortino(
|
||||
returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
periods_per_year: f64,
|
||||
) -> f64 {
|
||||
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let downside = adjusted
|
||||
.iter()
|
||||
.filter(|value| **value < 0.0)
|
||||
.map(|value| value.powi(2))
|
||||
.collect::<Vec<_>>();
|
||||
if downside.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
|
||||
.map(|value| value.min(0.0).powi(2))
|
||||
.sum::<f64>();
|
||||
let downside_dev = (downside / adjusted.len() as f64).sqrt();
|
||||
if downside_dev <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
@@ -357,32 +496,60 @@ fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_downside_risk(
|
||||
returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
periods_per_year: f64,
|
||||
) -> f64 {
|
||||
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let downside_mean_square = returns
|
||||
.iter()
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| (value - risk_free).min(0.0).powi(2))
|
||||
.sum::<f64>()
|
||||
/ returns.len() as f64;
|
||||
downside_mean_square.sqrt() * periods_per_year.sqrt()
|
||||
}
|
||||
|
||||
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
|
||||
std_dev(values) * periods_per_year.sqrt()
|
||||
}
|
||||
|
||||
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
|
||||
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
||||
fn alpha_beta(
|
||||
returns: &[f64],
|
||||
benchmark_returns: &[f64],
|
||||
daily_risk_free_rates: &[f64],
|
||||
) -> (f64, f64) {
|
||||
if returns.len() < 2
|
||||
|| returns.len() != benchmark_returns.len()
|
||||
|| returns.len() != daily_risk_free_rates.len()
|
||||
{
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let strategy_excess = returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let benchmark_excess = benchmark_returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.zip(daily_risk_free_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let mean_strategy = mean(&strategy_excess);
|
||||
let mean_benchmark = mean(&benchmark_excess);
|
||||
let variance_benchmark = variance(&benchmark_excess);
|
||||
let mean_raw_strategy = mean(returns);
|
||||
let mean_raw_benchmark = mean(benchmark_returns);
|
||||
let variance_benchmark = variance(benchmark_returns);
|
||||
if variance_benchmark <= f64::EPSILON {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let covariance = strategy_excess
|
||||
let covariance = returns
|
||||
.iter()
|
||||
.zip(benchmark_excess.iter())
|
||||
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
|
||||
.zip(benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| (lhs - mean_raw_strategy) * (rhs - mean_raw_benchmark))
|
||||
.sum::<f64>()
|
||||
/ (strategy_excess.len() - 1) as f64;
|
||||
let beta = covariance / variance_benchmark;
|
||||
@@ -522,6 +689,26 @@ where
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_monthly_risk_free_returns(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
daily_risk_free_rates: &[f64],
|
||||
) -> Vec<f64> {
|
||||
if equity_curve.len() != daily_risk_free_rates.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut monthly_growth = BTreeMap::<(i32, u32), f64>::new();
|
||||
for (point, daily_rate) in equity_curve.iter().zip(daily_risk_free_rates) {
|
||||
let growth = monthly_growth
|
||||
.entry((point.date.year(), point.date.month()))
|
||||
.or_insert(1.0);
|
||||
*growth *= 1.0 + daily_rate;
|
||||
}
|
||||
monthly_growth
|
||||
.into_values()
|
||||
.map(|growth| growth - 1.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mean(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
0.0
|
||||
@@ -587,6 +774,7 @@ mod tests {
|
||||
benchmark_prev_close: f64,
|
||||
) -> DailyEquityPoint {
|
||||
DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||
cash: total_equity,
|
||||
market_value: 0.0,
|
||||
@@ -606,16 +794,26 @@ mod tests {
|
||||
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
||||
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
|
||||
];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0);
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||
let expected = 7595.285 / 5957.717 - 1.0;
|
||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
|
||||
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
|
||||
baseline.signal_baseline=true;
|
||||
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
|
||||
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
|
||||
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).abs()<1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||
DailyEquityPoint {
|
||||
signal_baseline: false,
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash: 220.0,
|
||||
market_value: 0.0,
|
||||
@@ -635,9 +833,66 @@ mod tests {
|
||||
total_equity: 200.0,
|
||||
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
|
||||
}];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0);
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0, None).unwrap();
|
||||
assert!((metrics.total_return - 0.1).abs() < 1e-12);
|
||||
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
|
||||
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_adjusted_metrics_use_daily_pit_rates_and_all_period_downside() {
|
||||
let curve = vec![
|
||||
equity_point("2026-01-02", 101.0, 100.0, 100.0),
|
||||
equity_point("2026-01-05", 98.98, 100.0, 100.0),
|
||||
equity_point("2026-01-06", 100.4647, 100.0, 100.0),
|
||||
equity_point("2026-01-07", 99.9623765, 100.0, 100.0),
|
||||
];
|
||||
let annual_rates = [0.012, 0.012, 0.013, 0.013];
|
||||
let observations = curve
|
||||
.iter()
|
||||
.zip(annual_rates)
|
||||
.map(|(point, annual_rate)| RiskFreeRateObservation {
|
||||
date: point.date,
|
||||
source_date: point.date,
|
||||
annual_rate,
|
||||
daily_rate: (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0,
|
||||
})
|
||||
.collect();
|
||||
let contract = RiskFreeRateContract {
|
||||
version: "cn-government-bond-3m-pit-daily/v1".to_string(),
|
||||
source: "test".to_string(),
|
||||
tenor: "3M".to_string(),
|
||||
periods_per_year: TRADING_DAYS_PER_YEAR,
|
||||
max_staleness_days: 15,
|
||||
observed_max_staleness_days: 0,
|
||||
sha256: "a".repeat(64),
|
||||
observations,
|
||||
};
|
||||
let metrics =
|
||||
compute_backtest_metrics(&curve, &[], &[], &[], 100.0, Some(&contract)).unwrap();
|
||||
|
||||
let returns = [0.01, -0.02, 0.015, -0.005];
|
||||
let daily_rates = annual_rates
|
||||
.map(|annual_rate| (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0);
|
||||
let adjusted = returns
|
||||
.iter()
|
||||
.zip(daily_rates)
|
||||
.map(|(value, risk_free)| value - risk_free)
|
||||
.collect::<Vec<_>>();
|
||||
let expected_sharpe = mean(&adjusted) / std_dev(&adjusted) * TRADING_DAYS_PER_YEAR.sqrt();
|
||||
let downside = (adjusted
|
||||
.iter()
|
||||
.map(|value| value.min(0.0).powi(2))
|
||||
.sum::<f64>()
|
||||
/ adjusted.len() as f64)
|
||||
.sqrt();
|
||||
let expected_sortino = mean(&adjusted) / downside * TRADING_DAYS_PER_YEAR.sqrt();
|
||||
assert!((metrics.sharpe - expected_sharpe).abs() < 1e-12);
|
||||
assert!((metrics.sortino - expected_sortino).abs() < 1e-12);
|
||||
assert!((metrics.downside_risk - downside * TRADING_DAYS_PER_YEAR.sqrt()).abs() < 1e-12);
|
||||
assert_eq!(metrics.risk_free_rate_source, "test");
|
||||
assert_eq!(metrics.risk_free_rate_tenor, "3M");
|
||||
assert_eq!(metrics.risk_free_rate_observation_count, 4);
|
||||
assert_ne!(metrics.risk_free_rate, 0.022);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
|
||||
pub(crate) enum Value {
|
||||
Number(f64),
|
||||
Boolean(bool),
|
||||
Missing(ValueType),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
@@ -18,20 +19,28 @@ impl Value {
|
||||
match self {
|
||||
Self::Number(_) => ValueType::Number,
|
||||
Self::Boolean(_) => ValueType::Boolean,
|
||||
Self::Missing(value_type) => value_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_number(self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Number(value) => Some(value),
|
||||
Self::Boolean(_) => None,
|
||||
Self::Boolean(_) | Self::Missing(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_bool(self) -> Option<bool> {
|
||||
match self {
|
||||
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,
|
||||
Greater,
|
||||
GreaterEqual,
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -134,7 +145,7 @@ enum Instruction {
|
||||
Binary(BinaryOp),
|
||||
Call { builtin: Builtin, argc: u8 },
|
||||
JumpIfFalse(usize),
|
||||
JumpIfTrue(usize),
|
||||
ShortCircuit { on: bool, target: usize },
|
||||
Jump(usize),
|
||||
Return,
|
||||
}
|
||||
@@ -165,7 +176,7 @@ impl Program {
|
||||
let mut pc = 0usize;
|
||||
while let Some(instruction) = self.instructions.get(pc) {
|
||||
match *instruction {
|
||||
Instruction::Push(value) => scratch.stack.push(value),
|
||||
Instruction::Push(value) => scratch.stack.push(value.normalized()),
|
||||
Instruction::LoadVariable(index) => {
|
||||
let index = usize::from(index);
|
||||
let cached = scratch.variables[index];
|
||||
@@ -173,7 +184,8 @@ impl Program {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
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 {
|
||||
return Err(EvalError::new(format!(
|
||||
"variable {} expected {:?}, got {:?}",
|
||||
@@ -219,15 +231,23 @@ impl Program {
|
||||
scratch.stack.push(value);
|
||||
}
|
||||
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 {
|
||||
pc = target;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Instruction::JumpIfTrue(target) => {
|
||||
let condition = pop_bool(&mut scratch.stack)?;
|
||||
if condition {
|
||||
Instruction::ShortCircuit { on, target } => {
|
||||
let condition =
|
||||
scratch.stack.last().copied().ok_or_else(|| {
|
||||
EvalError::new("stack underflow during short circuit")
|
||||
})?;
|
||||
if condition.as_bool() == Some(on) {
|
||||
pc = target;
|
||||
continue;
|
||||
}
|
||||
@@ -284,43 +304,140 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
|
||||
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> {
|
||||
if value == Value::Missing(ValueType::Number) {
|
||||
return Ok(f64::NAN);
|
||||
}
|
||||
value
|
||||
.as_number()
|
||||
.ok_or_else(|| EvalError::new("numeric operand required"))
|
||||
}
|
||||
|
||||
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::Not => {
|
||||
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
||||
EvalError::new("boolean operand required for !")
|
||||
})?))
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(result?.normalized())
|
||||
}
|
||||
|
||||
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::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
|
||||
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
|
||||
BinaryOp::Divide => Ok(Value::Number(number(lhs)? / number(rhs)?)),
|
||||
BinaryOp::Remainder => Ok(Value::Number(number(lhs)? % number(rhs)?)),
|
||||
BinaryOp::Equal => Ok(Value::Boolean(lhs == rhs)),
|
||||
BinaryOp::NotEqual => Ok(Value::Boolean(lhs != rhs)),
|
||||
BinaryOp::Less => Ok(Value::Boolean(number(lhs)? < number(rhs)?)),
|
||||
BinaryOp::LessEqual => Ok(Value::Boolean(number(lhs)? <= number(rhs)?)),
|
||||
BinaryOp::Greater => Ok(Value::Boolean(number(lhs)? > number(rhs)?)),
|
||||
BinaryOp::GreaterEqual => Ok(Value::Boolean(number(lhs)? >= number(rhs)?)),
|
||||
BinaryOp::Equal => Ok(Value::Boolean(match (lhs, rhs) {
|
||||
(Value::Number(lhs), Value::Number(rhs)) => float_equal(lhs, rhs),
|
||||
(Value::Boolean(lhs), Value::Boolean(rhs)) => lhs == rhs,
|
||||
_ => {
|
||||
return Err(EvalError::new(
|
||||
"comparison operands must have the same type",
|
||||
));
|
||||
}
|
||||
})),
|
||||
BinaryOp::NotEqual => Ok(Value::Boolean(match (lhs, rhs) {
|
||||
(Value::Number(lhs), Value::Number(rhs)) => float_not_equal(lhs, rhs),
|
||||
(Value::Boolean(lhs), Value::Boolean(rhs)) => lhs != rhs,
|
||||
_ => {
|
||||
return Err(EvalError::new(
|
||||
"comparison operands must have the same type",
|
||||
));
|
||||
}
|
||||
})),
|
||||
BinaryOp::Less => {
|
||||
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
|
||||
Ok(Value::Boolean(
|
||||
(rhs - lhs) / float_comparison_scale(lhs, rhs) > f64::EPSILON,
|
||||
))
|
||||
}
|
||||
BinaryOp::LessEqual => {
|
||||
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
|
||||
Ok(Value::Boolean(
|
||||
(rhs - lhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON,
|
||||
))
|
||||
}
|
||||
BinaryOp::Greater => {
|
||||
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
|
||||
Ok(Value::Boolean(
|
||||
(lhs - rhs) / float_comparison_scale(lhs, rhs) > f64::EPSILON,
|
||||
))
|
||||
}
|
||||
BinaryOp::GreaterEqual => {
|
||||
let (lhs, rhs) = (number(lhs)?, number(rhs)?);
|
||||
Ok(Value::Boolean(
|
||||
(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 {
|
||||
if lhs * rhs == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
lhs.abs().max(rhs.abs())
|
||||
}
|
||||
}
|
||||
|
||||
fn float_equal(lhs: f64, rhs: f64) -> bool {
|
||||
(lhs - rhs).abs() / float_comparison_scale(lhs, rhs) <= f64::EPSILON
|
||||
}
|
||||
|
||||
fn float_not_equal(lhs: f64, rhs: f64) -> bool {
|
||||
(lhs - rhs).abs() / float_comparison_scale(lhs, rhs) > f64::EPSILON
|
||||
}
|
||||
|
||||
fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
||||
@@ -330,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
||||
.ok_or_else(|| EvalError::new("missing builtin argument"))
|
||||
.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::Floor => Value::Number(numeric(0)?.floor()),
|
||||
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
|
||||
@@ -341,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
||||
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
|
||||
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
||||
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 => {
|
||||
let value = numeric(0)?;
|
||||
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
||||
@@ -365,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
||||
})
|
||||
}
|
||||
Builtin::Iff => {
|
||||
let condition = args
|
||||
.first()
|
||||
.and_then(|value| value.as_bool())
|
||||
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?;
|
||||
let condition = match args.first().copied() {
|
||||
Some(Value::Boolean(value)) => value,
|
||||
Some(Value::Missing(ValueType::Boolean)) => false,
|
||||
_ => return Err(EvalError::new("iff condition must be boolean")),
|
||||
};
|
||||
if condition { args[1] } else { args[2] }
|
||||
}
|
||||
})
|
||||
};
|
||||
Ok(result.normalized())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -1067,24 +1201,20 @@ where
|
||||
let lhs_type = self.expression(lhs)?;
|
||||
require_type(lhs_type, ValueType::Boolean, position)?;
|
||||
let branch = self.instructions.len();
|
||||
self.instructions.push(match operator {
|
||||
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX),
|
||||
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX),
|
||||
_ => unreachable!(),
|
||||
self.instructions.push(Instruction::ShortCircuit {
|
||||
on: operator == ParsedBinaryOp::Or,
|
||||
target: usize::MAX,
|
||||
});
|
||||
let rhs_type = self.expression(rhs)?;
|
||||
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
|
||||
.push(Instruction::Push(Value::Boolean(matches!(
|
||||
operator,
|
||||
ParsedBinaryOp::Or
|
||||
))));
|
||||
.push(Instruction::Binary(if operator == ParsedBinaryOp::And {
|
||||
BinaryOp::And
|
||||
} else {
|
||||
BinaryOp::Or
|
||||
}));
|
||||
let end_target = self.instructions.len();
|
||||
patch_jump(&mut self.instructions, branch, short_target)?;
|
||||
patch_jump(&mut self.instructions, end_jump, end_target)?;
|
||||
patch_jump(&mut self.instructions, branch, end_target)?;
|
||||
return Ok(ValueType::Boolean);
|
||||
}
|
||||
|
||||
@@ -1233,7 +1363,7 @@ fn patch_jump(
|
||||
};
|
||||
match instruction {
|
||||
Instruction::JumpIfFalse(value)
|
||||
| Instruction::JumpIfTrue(value)
|
||||
| Instruction::ShortCircuit { target: value, .. }
|
||||
| Instruction::Jump(value) => {
|
||||
*value = target;
|
||||
Ok(())
|
||||
@@ -1313,6 +1443,122 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_comparisons_match_rhai_epsilon_semantics() {
|
||||
let adjacent = 11.699999999999998_f64;
|
||||
assert_eq!(
|
||||
evaluate("value == 11.7", &[("value", Value::Number(adjacent))]),
|
||||
Value::Boolean(true)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate("value != 11.7", &[("value", Value::Number(adjacent))]),
|
||||
Value::Boolean(false)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate("value <= 11.7", &[("value", Value::Number(adjacent))]),
|
||||
Value::Boolean(true)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate("value >= 11.7", &[("value", Value::Number(adjacent))]),
|
||||
Value::Boolean(true)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate("value < 11.7", &[("value", Value::Number(adjacent))]),
|
||||
Value::Boolean(false)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate("value > 11.7", &[("value", Value::Number(adjacent))]),
|
||||
Value::Boolean(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn short_circuit_does_not_resolve_unused_variable() {
|
||||
let program = compile("false && missing", |name| {
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Explicit reference identities and frozen rank universes shared by all daily runtimes.
|
||||
use crate::{
|
||||
daily_patterns::{dataset_series, evaluate_with_context, PatternSpec, ResearchContext},
|
||||
factor_events::{field_dependencies, Expr},
|
||||
DataSet,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const CONTRACT: &str = "fidc_pattern_execution_context_v1";
|
||||
pub const CONTEXT_FIELDS: &[&str] = &[
|
||||
"index_open",
|
||||
"index_high",
|
||||
"index_low",
|
||||
"index_close",
|
||||
"scope_rank",
|
||||
"scope_percentile",
|
||||
"scope_size",
|
||||
];
|
||||
const STOCK_FIELDS: &[&str] = &[
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"close",
|
||||
"volume",
|
||||
"raw_open",
|
||||
"raw_high",
|
||||
"raw_low",
|
||||
"raw_close",
|
||||
"prev_close",
|
||||
"amount",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionContext {
|
||||
pub contract: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub benchmark: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rank_expression: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rank_universe: Vec<String>,
|
||||
}
|
||||
|
||||
fn valid_symbol(s: &str) -> bool {
|
||||
let Some((code, market)) = s.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|c| c.is_ascii_digit())
|
||||
&& matches!(market, "SH" | "SZ" | "BJ" | "CSI")
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
pub fn fields(&self, expression: &Expr) -> BTreeSet<String> {
|
||||
let mut fields = field_dependencies(expression);
|
||||
if let Some(rank) = &self.rank_expression {
|
||||
fields.extend(field_dependencies(rank));
|
||||
}
|
||||
fields
|
||||
}
|
||||
pub fn validate(&self, expression: &Expr) -> Result<(), String> {
|
||||
if self.contract != CONTRACT {
|
||||
return Err("pattern_context_contract_invalid".into());
|
||||
}
|
||||
let needed = field_dependencies(expression);
|
||||
let ranked = needed.iter().any(|f| f.starts_with("scope_"));
|
||||
if ranked != self.rank_expression.is_some() || !ranked && !self.rank_universe.is_empty() {
|
||||
return Err("pattern_rank_expression_and_universe_required".into());
|
||||
}
|
||||
if ranked
|
||||
&& (self.rank_universe.len() < 2
|
||||
|| self.rank_universe.len() > 20_000
|
||||
|| self.rank_universe.iter().any(|s| !valid_symbol(s))
|
||||
|| self.rank_universe.iter().collect::<BTreeSet<_>>().len()
|
||||
!= self.rank_universe.len())
|
||||
{
|
||||
return Err("pattern_rank_universe_invalid".into());
|
||||
}
|
||||
if let Some(rank) = &self.rank_expression {
|
||||
let fields = field_dependencies(rank);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !f.starts_with("index_"))
|
||||
{
|
||||
return Err("pattern_rank_expression_invalid_or_recursive".into());
|
||||
}
|
||||
}
|
||||
let fields = self.fields(expression);
|
||||
if fields
|
||||
.iter()
|
||||
.any(|f| !STOCK_FIELDS.contains(&f.as_str()) && !CONTEXT_FIELDS.contains(&f.as_str()))
|
||||
{
|
||||
return Err("pattern_context_unmapped_field".into());
|
||||
}
|
||||
let index = fields.iter().any(|f| f.starts_with("index_"));
|
||||
if index != self.benchmark.is_some()
|
||||
|| self
|
||||
.benchmark
|
||||
.as_ref()
|
||||
.is_some_and(|s| !valid_symbol(s) || s.ends_with(".BJ"))
|
||||
{
|
||||
return Err("pattern_reference_index_required".into());
|
||||
}
|
||||
if !index && !ranked {
|
||||
return Err("pattern_unused_context".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_dataset_context(
|
||||
spec: &PatternSpec,
|
||||
data: &DataSet,
|
||||
date: NaiveDate,
|
||||
) -> Result<ResearchContext, String> {
|
||||
let Some(config) = &spec.execution_context else {
|
||||
return Ok(ResearchContext::default());
|
||||
};
|
||||
config.validate(
|
||||
spec.expression
|
||||
.as_ref()
|
||||
.ok_or("pattern_context_requires_expression")?,
|
||||
)?;
|
||||
let days = data.calendar().trailing_days(date, spec.history_len());
|
||||
if days.len() != spec.history_len() || days.last() != Some(&date) {
|
||||
return Err("pattern_context_calendar_incomplete".into());
|
||||
}
|
||||
let needed = config.fields(spec.expression.as_ref().unwrap());
|
||||
let mut context = ResearchContext::default();
|
||||
if let Some(symbol) = &config.benchmark {
|
||||
for name in needed.iter().filter(|f| f.starts_with("index_")) {
|
||||
let values = days
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let value = if let Some(b) = data.market(*d, symbol) {
|
||||
match name.as_str() {
|
||||
"index_open" => Some(b.open),
|
||||
"index_high" => Some(b.high),
|
||||
"index_low" => Some(b.low),
|
||||
"index_close" => Some(b.close),
|
||||
_ => None,
|
||||
}
|
||||
} else if let Some(b) = data.benchmark(*d).filter(|b| &b.benchmark == symbol) {
|
||||
match name.as_str() {
|
||||
"index_open" => Some(b.open),
|
||||
"index_close" => Some(b.close),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
value
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("pattern_reference_missing: {symbol} {d} {name}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
context.common.insert(name.clone(), values);
|
||||
}
|
||||
}
|
||||
if let Some(expression) = &config.rank_expression {
|
||||
let mut input = spec.clone();
|
||||
input.execution_context = None;
|
||||
input.expression = Some(expression.clone());
|
||||
let mut values = BTreeMap::new();
|
||||
for symbol in &config.rank_universe {
|
||||
let row = evaluate_with_context(
|
||||
&input,
|
||||
&days,
|
||||
&dataset_series(data, &days, symbol),
|
||||
&context.common,
|
||||
true,
|
||||
)?;
|
||||
if let Some(reason) = row.exclusion {
|
||||
return Err(format!("pattern_rank_member_incomplete: {symbol} {reason}"));
|
||||
}
|
||||
values.insert(
|
||||
symbol.clone(),
|
||||
serde_json::from_value::<Vec<Option<f64>>>(
|
||||
row.values["expression"]["values"].clone(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
let ranks =
|
||||
crate::factor_cross_section::rank_history(&days, &config.rank_universe, &values)?;
|
||||
for symbol in &config.rank_universe {
|
||||
let decode = |value: &Value| {
|
||||
serde_json::from_value::<Vec<Option<f64>>>(value.clone()).map_err(|e| e.to_string())
|
||||
};
|
||||
context.by_symbol.insert(
|
||||
symbol.clone(),
|
||||
BTreeMap::from([
|
||||
("scope_rank".into(), decode(&ranks["rank"][symbol])?),
|
||||
(
|
||||
"scope_percentile".into(),
|
||||
decode(&ranks["percentile"][symbol])?,
|
||||
),
|
||||
(
|
||||
"scope_size".into(),
|
||||
vec![Some(config.rank_universe.len() as f64); days.len()],
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
let mut specs = Vec::new();
|
||||
match value {
|
||||
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||
Value::Array(items) => {
|
||||
for v in items {
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
Value::Object(items) => {
|
||||
for v in items.values() {
|
||||
specs.extend(specs_in_value(v)?);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub fn required_symbols(value: &Value) -> Result<(BTreeSet<String>, BTreeSet<String>), String> {
|
||||
let (mut indices, mut stocks) = (BTreeSet::new(), BTreeSet::new());
|
||||
for spec in specs_in_value(value)? {
|
||||
if let Some(context) = spec.execution_context {
|
||||
if let Some(index) = context.benchmark {
|
||||
indices.insert(index);
|
||||
}
|
||||
stocks.extend(context.rank_universe);
|
||||
}
|
||||
}
|
||||
Ok((indices, stocks))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
|
||||
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
|
||||
assert!(serde_json::to_value(expression).unwrap().get("window").is_none());
|
||||
}
|
||||
fn data(future: bool, reference: bool) -> DataSet {
|
||||
let mut days = vec![
|
||||
NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 9, 8).unwrap(),
|
||||
];
|
||||
if future {
|
||||
days.push(NaiveDate::from_ymd_opt(2026, 9, 9).unwrap());
|
||||
}
|
||||
let symbols = vec!["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
let mut instruments = symbols
|
||||
.iter()
|
||||
.map(|s| Instrument {
|
||||
symbol: s.to_string(),
|
||||
name: s.to_string(),
|
||||
board: "SZ_MAIN".into(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if reference {
|
||||
instruments.push(Instrument {
|
||||
symbol: "399006.SZ".into(),
|
||||
name: "reference".into(),
|
||||
board: "INDEX".into(),
|
||||
round_lot: 1,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
});
|
||||
}
|
||||
let mut market = vec![];
|
||||
let mut factors = vec![];
|
||||
let mut benchmark = vec![];
|
||||
for (i, d) in days.iter().enumerate() {
|
||||
for (n, s) in symbols.iter().enumerate() {
|
||||
let c = [
|
||||
[10., 12., 11., 1000.],
|
||||
[10., 11., 12., 1.],
|
||||
[10., 10., 13., 1.],
|
||||
][n][i];
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
timestamp: None,
|
||||
day_open: c,
|
||||
open: c,
|
||||
high: c,
|
||||
low: c,
|
||||
close: c,
|
||||
last_price: c,
|
||||
bid1: c,
|
||||
ask1: c,
|
||||
prev_close: 10.,
|
||||
volume: 100000,
|
||||
minute_volume: 0,
|
||||
bid1_volume: 10000,
|
||||
ask1_volume: 10000,
|
||||
trading_phase: None,
|
||||
paused: false,
|
||||
upper_limit: c * 2.,
|
||||
lower_limit: c / 2.,
|
||||
price_tick: 0.01,
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: *d,
|
||||
symbol: s.to_string(),
|
||||
market_cap_bn: 1.,
|
||||
free_float_cap_bn: 1.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
}
|
||||
if reference {
|
||||
let mut row = market.last().unwrap().clone();
|
||||
row.symbol = "399006.SZ".into();
|
||||
row.open = 30.;
|
||||
row.high = 30.;
|
||||
row.low = 30.;
|
||||
row.close = 30.;
|
||||
market.push(row);
|
||||
}
|
||||
benchmark.push(BenchmarkSnapshot {
|
||||
date: *d,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 4000.,
|
||||
close: 4000.,
|
||||
prev_close: 4000.,
|
||||
volume: 1000,
|
||||
});
|
||||
}
|
||||
DataSet::from_components(instruments, market, factors, vec![], benchmark).unwrap()
|
||||
}
|
||||
fn spec(rank: bool) -> PatternSpec {
|
||||
let expression = if rank {
|
||||
json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"scope_rank"},{"kind":"number","value":2}]})
|
||||
} else {
|
||||
json!({"kind":"operator","name":"LT","args":[{"kind":"field","name":"index_close"},{"kind":"number","value":100}]})
|
||||
};
|
||||
let context = if rank {
|
||||
json!({"contract":CONTRACT,"rank_expression":{"kind":"operator","name":"PCT_CHANGE","window":1,"args":[{"kind":"field","name":"close"}]},"rank_universe":["000001.SZ","000002.SZ","000003.SZ"]})
|
||||
} else {
|
||||
json!({"contract":CONTRACT,"benchmark":"399006.SZ"})
|
||||
};
|
||||
serde_json::from_value::<PatternSpec>(json!({"template":"expression","parameters":{"history_window":3},"expression":expression,"execution_context":context})).unwrap().validate().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn dataset_rank_is_full_scope_causal_and_equal_to_pure_cross_section() {
|
||||
let spec = spec(true);
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
let original = build_dataset_context(&spec, &data(false, true), date).unwrap();
|
||||
let future = build_dataset_context(&spec, &data(true, true), date).unwrap();
|
||||
assert_eq!(original.by_symbol, future.by_symbol);
|
||||
assert_eq!(original.by_symbol["000001.SZ"]["scope_rank"][2], Some(3.));
|
||||
assert_eq!(original.by_symbol["000002.SZ"]["scope_rank"][2], Some(2.));
|
||||
assert_eq!(original.by_symbol["000003.SZ"]["scope_rank"][2], Some(1.));
|
||||
assert!(
|
||||
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
let mut incomplete = data(false, true).snapshot_components();
|
||||
incomplete.market.retain(|r| r.symbol != "000003.SZ");
|
||||
let broken = DataSet::from_components(
|
||||
incomplete.instruments,
|
||||
incomplete.market,
|
||||
incomplete.factors,
|
||||
incomplete.candidates,
|
||||
incomplete.benchmarks,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(build_dataset_context(&spec, &broken, date).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reference_index_never_defaults_to_performance_benchmark() {
|
||||
let spec = spec(false);
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
assert!(
|
||||
crate::daily_patterns::evaluate_dataset(&spec, &data(false, true), date, "000001.SZ")
|
||||
.unwrap()
|
||||
.matched
|
||||
);
|
||||
assert!(build_dataset_context(&spec, &data(false, false), date)
|
||||
.unwrap_err()
|
||||
.contains("399006.SZ"));
|
||||
}
|
||||
#[test]
|
||||
fn runtime_contract_rejects_missing_range_and_recursive_ranks() {
|
||||
let mut missing = spec(true);
|
||||
missing
|
||||
.execution_context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.rank_universe
|
||||
.clear();
|
||||
assert!(missing.validate().is_err());
|
||||
let mut recursive = spec(true);
|
||||
recursive
|
||||
.execution_context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.rank_expression = Some(Expr::Field {
|
||||
name: "scope_rank".into(),
|
||||
});
|
||||
assert!(recursive.validate().is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -227,6 +227,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
|
||||
"factor",
|
||||
"day_factor",
|
||||
"rolling_mean",
|
||||
"pattern_signal",
|
||||
"pattern_score",
|
||||
"rolling_mean_current",
|
||||
"rolling_max_current",
|
||||
"rolling_return_stddev_current",
|
||||
|
||||
@@ -3,14 +3,15 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::portfolio_loss::PortfolioLossConfig;
|
||||
|
||||
use crate::{
|
||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
|
||||
RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection,
|
||||
futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||
PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||
futures::FuturesDirection, futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -42,6 +43,8 @@ pub struct StrategyRuntimeSpec {
|
||||
pub metadata: Option<Value>,
|
||||
#[serde(default, alias = "factor_value_bindings")]
|
||||
pub factor_value_bindings: Vec<Value>,
|
||||
#[serde(default, alias = "stock_pool_factor_contract")]
|
||||
pub stock_pool_factor_contract: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub parameters: Option<Value>,
|
||||
#[serde(default)]
|
||||
@@ -60,6 +63,8 @@ pub struct StrategyBenchmarkSpec {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyUniverseSpec {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub include: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
@@ -268,6 +273,12 @@ pub struct StrategyEngineConfig {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyRiskPolicySpec {
|
||||
#[serde(default, alias = "max_order_quantity", alias = "maxOrderQuantity")]
|
||||
pub max_order_quantity: Option<f64>,
|
||||
#[serde(default, alias = "max_order_notional", alias = "maxOrderNotional")]
|
||||
pub max_order_notional: Option<f64>,
|
||||
#[serde(default, alias = "max_symbol_position", alias = "maxSymbolPosition")]
|
||||
pub max_symbol_position: Option<f64>,
|
||||
#[serde(default, alias = "reject_st_selection", alias = "rejectStSelection")]
|
||||
pub reject_st_selection: Option<bool>,
|
||||
#[serde(default, alias = "reject_st_buy", alias = "rejectStBuy")]
|
||||
@@ -330,6 +341,15 @@ pub struct StrategyRiskPolicySpec {
|
||||
alias = "blacklist"
|
||||
)]
|
||||
pub blacklisted_symbols: Vec<String>,
|
||||
/// Account- and strategy-scoped blacklist facts are injected by the
|
||||
/// trading platform before runtime execution. They are typed here so the
|
||||
/// shared contract validator accepts the context without silently
|
||||
/// discarding malformed values; the trading risk layer applies the
|
||||
/// account/strategy match with the actual execution identity.
|
||||
#[serde(default, alias = "accountBlacklistedInstruments")]
|
||||
pub account_blacklisted_instruments: BTreeMap<String, BTreeSet<String>>,
|
||||
#[serde(default, alias = "strategyBlacklistedInstruments")]
|
||||
pub strategy_blacklisted_instruments: BTreeMap<String, BTreeSet<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "volume_limit_enabled",
|
||||
@@ -412,6 +432,9 @@ const RISK_POLICY_BOOL_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
];
|
||||
|
||||
const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
("maxOrderQuantity", &["max_order_quantity"]),
|
||||
("maxOrderNotional", &["max_order_notional"]),
|
||||
("maxSymbolPosition", &["max_symbol_position"]),
|
||||
("volumePercent", &["volume_percent"]),
|
||||
("commissionRate", &["commission_rate"]),
|
||||
(
|
||||
@@ -666,8 +689,14 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
("matchingType", &["matching_type"]),
|
||||
("slippageModel", &["slippage_model"]),
|
||||
("slippageValue", &["slippage_value"]),
|
||||
("slippageImpactCoefficient", &["slippage_impact_coefficient"]),
|
||||
("slippageVolatilityCoefficient", &["slippage_volatility_coefficient"]),
|
||||
(
|
||||
"slippageImpactCoefficient",
|
||||
&["slippage_impact_coefficient"],
|
||||
),
|
||||
(
|
||||
"slippageVolatilityCoefficient",
|
||||
&["slippage_volatility_coefficient"],
|
||||
),
|
||||
(
|
||||
"slippageMaxValue",
|
||||
&["slippage_max_value", "slippage_max_rate"],
|
||||
@@ -679,7 +708,10 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||
),
|
||||
("transferFeeRate", &["transfer_fee_rate", "transferFeeRate"]),
|
||||
("stampTaxRate", &["stamp_tax_rate"]),
|
||||
("stampTaxRateBeforeChange", &["stamp_tax_rate_before_change"]),
|
||||
(
|
||||
"stampTaxRateBeforeChange",
|
||||
&["stamp_tax_rate_before_change"],
|
||||
),
|
||||
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
||||
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
||||
("volumeLimit", &["volume_limit"]),
|
||||
@@ -861,6 +893,10 @@ pub struct StrategyExpressionSelectionConfig {
|
||||
pub market_cap_upper_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stock_filter_expr: Option<String>,
|
||||
#[serde(default, alias = "current_day_precomputed_factors")]
|
||||
pub current_day_precomputed_factors: Option<bool>,
|
||||
#[serde(default, alias = "candidate_symbols_by_date")]
|
||||
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -875,12 +911,18 @@ pub struct StrategyExpressionAllocationConfig {
|
||||
pub struct StrategyExpressionRiskConfig {
|
||||
#[serde(default)]
|
||||
pub exposure_expr: Option<String>,
|
||||
#[serde(default, alias = "position_exposure_schedule")]
|
||||
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
|
||||
#[serde(default)]
|
||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||
#[serde(default)]
|
||||
pub portfolio_loss_control: Option<StrategyPortfolioLossControlConfig>,
|
||||
#[serde(default)]
|
||||
pub stop_loss_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub take_profit_expr: Option<String>,
|
||||
#[serde(default, alias = "position_target_rules")]
|
||||
pub position_target_rules: Vec<StrategyPositionTargetRule>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "referencePriceMode",
|
||||
@@ -889,6 +931,26 @@ pub struct StrategyExpressionRiskConfig {
|
||||
pub stop_take_reference_price_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyPositionTargetRule {
|
||||
#[serde(alias = "when_expr")]
|
||||
pub when_expr: String,
|
||||
#[serde(alias = "remaining_position_bps", alias = "remainingBps")]
|
||||
pub remaining_position_bps: u32,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyPositionExposureSchedulePoint {
|
||||
#[serde(alias = "effective_date")]
|
||||
pub effective_date: String,
|
||||
#[serde(alias = "target_exposure_bps", alias = "effective_bps")]
|
||||
pub target_exposure_bps: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyPortfolioDrawdownControlConfig {
|
||||
@@ -904,6 +966,16 @@ pub struct StrategyPortfolioDrawdownControlConfig {
|
||||
pub cooldown_trading_days: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StrategyPortfolioLossControlConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub lookback: Option<usize>,
|
||||
pub loss_trigger: Option<f64>,
|
||||
pub floor_exposure: Option<f64>,
|
||||
pub cooldown_trading_days: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyExpressionOrderingConfig {
|
||||
@@ -918,6 +990,8 @@ pub struct StrategyExpressionOrderingConfig {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyExpressionTradingConfig {
|
||||
#[serde(default, alias = "buy_filter_expr")]
|
||||
pub buy_filter_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stage: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -963,6 +1037,10 @@ pub struct StrategyExpressionTradingConfig {
|
||||
#[serde(default)]
|
||||
pub release_slot_on_exit_signal: Option<bool>,
|
||||
#[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>,
|
||||
#[serde(default)]
|
||||
pub subscriptions: Vec<String>,
|
||||
@@ -1042,6 +1120,7 @@ pub fn platform_expr_config_from_value(
|
||||
.map_err(platform_config_error);
|
||||
}
|
||||
reject_removed_compatibility_fields(value).map_err(platform_config_error)?;
|
||||
validate_strategy_risk_policy_fields(value).map_err(platform_config_error)?;
|
||||
let mut value = value.clone();
|
||||
normalize_strategy_aliases_in_value(&mut value).map_err(platform_config_error)?;
|
||||
normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?;
|
||||
@@ -1050,6 +1129,73 @@ pub fn platform_expr_config_from_value(
|
||||
.map_err(platform_config_error)
|
||||
}
|
||||
|
||||
/// Reject misspelled or unsupported fields inside a strategy risk policy.
|
||||
/// Generic JSON deserialization otherwise ignores unknown keys and makes a
|
||||
/// strategy appear protected while silently using the process default.
|
||||
pub fn validate_strategy_risk_policy_fields(value: &Value) -> Result<(), String> {
|
||||
let mut allowed = BTreeSet::<&str>::new();
|
||||
for (canonical, aliases) in RISK_POLICY_BOOL_ALIAS_GROUPS
|
||||
.iter()
|
||||
.chain(RISK_POLICY_VALUE_ALIAS_GROUPS.iter())
|
||||
{
|
||||
allowed.insert(*canonical);
|
||||
for key in *aliases {
|
||||
allowed.insert(*key);
|
||||
}
|
||||
}
|
||||
for key in [
|
||||
"blacklistedSymbols",
|
||||
"blacklisted_symbols",
|
||||
"blacklistedInstruments",
|
||||
"blacklisted_instruments",
|
||||
"blacklist",
|
||||
"accountBlacklistedInstruments",
|
||||
"account_blacklisted_instruments",
|
||||
"strategyBlacklistedInstruments",
|
||||
"strategy_blacklisted_instruments",
|
||||
// Legacy execution aliases are accepted by StrategyExecutionSpec and
|
||||
// normalized into the same shared switches.
|
||||
"volumeLimit",
|
||||
"volume_limit",
|
||||
"liquidityLimit",
|
||||
"liquidity_limit",
|
||||
] {
|
||||
allowed.insert(key);
|
||||
}
|
||||
|
||||
fn walk(value: &Value, allowed: &BTreeSet<&str>, path: &str) -> Result<(), String> {
|
||||
let Some(object) = value.as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
for (key, child) in object {
|
||||
let child_path = if path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{path}.{key}")
|
||||
};
|
||||
if matches!(key.as_str(), "riskPolicy" | "risk_policy") {
|
||||
if child.is_null() {
|
||||
// Typed specs serialize an omitted Option policy as null;
|
||||
// that is equivalent to an absent strategy policy.
|
||||
continue;
|
||||
}
|
||||
let Some(policy) = child.as_object() else {
|
||||
return Err(format!("{child_path} must be a JSON object"));
|
||||
};
|
||||
for policy_key in policy.keys() {
|
||||
if !allowed.contains(policy_key.as_str()) {
|
||||
return Err(format!("unsupported riskPolicy field: {policy_key}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(child, allowed, &child_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
walk(value, &allowed, "")
|
||||
}
|
||||
|
||||
fn reject_removed_compatibility_fields(value: &Value) -> Result<(), String> {
|
||||
const SECTION_NAMES: [&str; 3] = ["engineConfig", "engine_config", "execution"];
|
||||
const FIELD_NAMES: [&str; 4] = [
|
||||
@@ -1095,6 +1241,14 @@ fn valid_non_negative(value: Option<f64>) -> Option<f64> {
|
||||
value.filter(|item| item.is_finite() && *item >= 0.0)
|
||||
}
|
||||
|
||||
fn valid_positive_limit(value: Option<f64>, field_name: &str) -> Result<Option<f64>, String> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(item) if item.is_finite() && item > 0.0 => Ok(Some(item)),
|
||||
Some(_) => Err(format!("{field_name} must be a positive finite number")),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_percent_ratio(value: f64, field_name: &str) -> Result<f64, String> {
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
return Err(format!("{field_name} must be a positive finite number"));
|
||||
@@ -1122,6 +1276,9 @@ fn parse_stop_take_reference_price_mode(
|
||||
"position_cost_basis" | "position_cost" | "execution_cost_basis" => {
|
||||
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
|
||||
}
|
||||
"position_average_entry_price" | "position_avg_price" | "average_entry_price" => {
|
||||
Ok(PlatformStopTakeReferencePriceMode::PositionAverageEntryPrice)
|
||||
}
|
||||
"signal_day_post_adjusted_close"
|
||||
| "signal_post_adjusted_close"
|
||||
| "model_signal_post_adjusted_close" => {
|
||||
@@ -1204,7 +1361,13 @@ fn apply_flat_risk_overrides(
|
||||
|
||||
fn sync_quote_quantity_limit(cfg: &mut PlatformExprStrategyConfig) {
|
||||
cfg.quote_quantity_limit = cfg.risk_config.trading_constraints.liquidity_limit_enabled
|
||||
|| cfg.matching_type != MatchingType::MinuteLast;
|
||||
|| matches!(
|
||||
cfg.matching_type,
|
||||
MatchingType::MinuteBestOwn
|
||||
| MatchingType::MinuteBestCounterparty
|
||||
| MatchingType::Vwap
|
||||
| MatchingType::Twap
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_risk_policy_overrides(
|
||||
@@ -1214,6 +1377,21 @@ fn apply_risk_policy_overrides(
|
||||
let Some(policy) = policy else {
|
||||
return Ok(());
|
||||
};
|
||||
let max_order_quantity =
|
||||
valid_positive_limit(policy.max_order_quantity, "riskPolicy.maxOrderQuantity")?;
|
||||
let max_order_notional =
|
||||
valid_positive_limit(policy.max_order_notional, "riskPolicy.maxOrderNotional")?;
|
||||
let max_symbol_position =
|
||||
valid_positive_limit(policy.max_symbol_position, "riskPolicy.maxSymbolPosition")?;
|
||||
if let Some(value) = max_order_quantity {
|
||||
cfg.risk_config.trading_constraints.max_order_quantity = value;
|
||||
}
|
||||
if let Some(value) = max_order_notional {
|
||||
cfg.risk_config.trading_constraints.max_order_notional = value;
|
||||
}
|
||||
if let Some(value) = max_symbol_position {
|
||||
cfg.risk_config.trading_constraints.max_symbol_position = value;
|
||||
}
|
||||
let static_rules = &mut cfg.risk_config.static_rules;
|
||||
if let Some(value) = policy.reject_st_selection {
|
||||
static_rules.reject_st_selection = value;
|
||||
@@ -1622,6 +1800,23 @@ pub fn platform_expr_config_from_spec(
|
||||
let Some(spec) = strategy_spec else {
|
||||
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_long_explicit = false;
|
||||
let mut stock_short_explicit = false;
|
||||
@@ -1783,6 +1978,27 @@ pub fn platform_expr_config_from_spec(
|
||||
}
|
||||
}
|
||||
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
|
||||
.exclude
|
||||
.iter()
|
||||
@@ -1891,6 +2107,34 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.stock_filter_expr = expr.clone();
|
||||
}
|
||||
if let Some(enabled) = selection.current_day_precomputed_factors {
|
||||
cfg.current_day_precomputed_factors = enabled;
|
||||
}
|
||||
for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date {
|
||||
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
|
||||
format!("candidateSymbolsByDate contains invalid date: {raw_date}")
|
||||
})?;
|
||||
let mut symbols = BTreeSet::new();
|
||||
for raw_symbol in raw_symbols {
|
||||
let symbol = normalize_symbol(raw_symbol, None);
|
||||
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "BJ")
|
||||
});
|
||||
if !valid {
|
||||
return Err(format!(
|
||||
"candidateSymbolsByDate contains invalid stock symbol: {raw_symbol}"
|
||||
));
|
||||
}
|
||||
if !symbols.insert(symbol.clone()) {
|
||||
return Err(format!(
|
||||
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
|
||||
));
|
||||
}
|
||||
}
|
||||
cfg.candidate_symbols_by_date.insert(trade_date, symbols);
|
||||
}
|
||||
}
|
||||
if let Some(allocation) = runtime_expr.allocation.as_ref()
|
||||
&& let Some(expr) = allocation
|
||||
@@ -1917,6 +2161,46 @@ pub fn platform_expr_config_from_spec(
|
||||
expr.clone()
|
||||
};
|
||||
}
|
||||
for point in &risk.position_exposure_schedule {
|
||||
let effective_date = NaiveDate::parse_from_str(
|
||||
point.effective_date.trim(),
|
||||
"%Y-%m-%d",
|
||||
)
|
||||
.map_err(|_| {
|
||||
"runtimeExpressions.risk.positionExposureSchedule effectiveDate must use YYYY-MM-DD"
|
||||
.to_string()
|
||||
})?;
|
||||
if !(0..=10_000).contains(&point.target_exposure_bps) {
|
||||
return Err(
|
||||
"runtimeExpressions.risk.positionExposureSchedule targetExposureBps must be between 0 and 10000"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if cfg
|
||||
.position_exposure_schedule
|
||||
.insert(
|
||||
effective_date,
|
||||
f64::from(point.target_exposure_bps) / 10_000.0,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"runtimeExpressions.risk.positionExposureSchedule contains duplicate date {effective_date}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(control) = risk.portfolio_loss_control.as_ref()
|
||||
&& control.enabled.unwrap_or(true)
|
||||
{
|
||||
let parsed = PortfolioLossConfig {
|
||||
lookback: control.lookback.ok_or("portfolioLossControl.lookback is required")?,
|
||||
loss_trigger: control.loss_trigger.ok_or("portfolioLossControl.lossTrigger is required")?,
|
||||
floor_exposure: control.floor_exposure.ok_or("portfolioLossControl.floorExposure is required")?,
|
||||
cooldown_trading_days: control.cooldown_trading_days.ok_or("portfolioLossControl.cooldownTradingDays is required")?,
|
||||
};
|
||||
parsed.validate().map_err(|error| error.to_string())?;
|
||||
cfg.portfolio_loss_control = Some(parsed);
|
||||
}
|
||||
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
||||
&& control.enabled.unwrap_or(true)
|
||||
{
|
||||
@@ -1968,6 +2252,37 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.take_profit_expr = expr.clone();
|
||||
}
|
||||
let mut position_target_identities = BTreeSet::new();
|
||||
for (index, rule) in risk.position_target_rules.iter().enumerate() {
|
||||
let when_expr = rule.when_expr.trim();
|
||||
if when_expr.is_empty() {
|
||||
return Err(format!(
|
||||
"runtimeExpressions.risk.positionTargetRules[{index}].whenExpr cannot be empty"
|
||||
));
|
||||
}
|
||||
if rule.remaining_position_bps >= 10_000 {
|
||||
return Err(format!(
|
||||
"runtimeExpressions.risk.positionTargetRules[{index}].remainingPositionBps must be between 0 and 9999"
|
||||
));
|
||||
}
|
||||
let identity = (when_expr.to_string(), rule.remaining_position_bps);
|
||||
if !position_target_identities.insert(identity) {
|
||||
return Err(format!(
|
||||
"runtimeExpressions.risk.positionTargetRules[{index}] is duplicated"
|
||||
));
|
||||
}
|
||||
cfg.position_target_rules.push(PlatformPositionTargetRule {
|
||||
when_expr: when_expr.to_string(),
|
||||
remaining_position_bps: rule.remaining_position_bps,
|
||||
reason: rule
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("factor_position_target")
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(mode) = risk
|
||||
.stop_take_reference_price_mode
|
||||
.as_deref()
|
||||
@@ -2001,6 +2316,9 @@ pub fn platform_expr_config_from_spec(
|
||||
}
|
||||
}
|
||||
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
|
||||
.refresh_rate_expr
|
||||
.as_ref()
|
||||
@@ -2053,6 +2371,12 @@ pub fn platform_expr_config_from_spec(
|
||||
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
||||
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 {
|
||||
cfg.delayed_limit_open_exit_enabled = enabled;
|
||||
if enabled {
|
||||
@@ -2214,6 +2538,10 @@ pub fn platform_expr_config_from_spec(
|
||||
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
||||
}
|
||||
let trade_times = spec_trade_times(spec);
|
||||
if crate::pattern_context::specs_in_value(&serde_json::to_value(spec).map_err(|e|e.to_string())?)?.iter().any(|p|p.template=="session_event") {
|
||||
if trade_times.is_empty() {return Err("session_event_requires_explicit_trade_times".into());}
|
||||
cfg.session_event_times=trade_times.clone();
|
||||
}
|
||||
let explicit_trading_schedule = spec
|
||||
.runtime_expressions
|
||||
.as_ref()
|
||||
@@ -2755,6 +3083,15 @@ fn normalize_symbol(symbol: &str, raw_board: Option<&str>) -> String {
|
||||
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 {
|
||||
if symbol.contains('.') {
|
||||
return symbol.to_ascii_uppercase();
|
||||
@@ -2777,8 +3114,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String {
|
||||
}
|
||||
|
||||
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
||||
let has_suffix = symbol.trim().rsplit_once('.').is_some();
|
||||
if has_suffix && symbol_is_kcb(symbol) {
|
||||
if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) {
|
||||
return "KSH".to_string();
|
||||
}
|
||||
let normalized = raw_board
|
||||
@@ -2793,9 +3129,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
||||
if let Some((_, suffix)) = symbol.rsplit_once('.') {
|
||||
return suffix.to_ascii_uppercase();
|
||||
}
|
||||
if symbol_is_kcb(symbol) {
|
||||
return "KSH".to_string();
|
||||
}
|
||||
if symbol.starts_with('8') || symbol.starts_with('4') {
|
||||
return "BJ".to_string();
|
||||
}
|
||||
@@ -2812,27 +3145,46 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() {
|
||||
assert_eq!(normalize_board("688001.SH", None), "KSH");
|
||||
assert_eq!(normalize_board("689001.SH", None), "KSH");
|
||||
fn parses_buy_filter_as_a_separate_trading_condition() {
|
||||
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
|
||||
"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("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.SH", Some("SH")), "KSH");
|
||||
assert_eq!(normalize_board("688001.SH", Some("SH")), "SH");
|
||||
assert_eq!(normalize_board("000001.SZ", Some("KSH")), "KSH");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2841,14 +3193,22 @@ mod tests {
|
||||
"strategyId": "runtime_spec_test",
|
||||
"signalSymbol": "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": {
|
||||
"prelude": "let stocknum = 8;",
|
||||
"selection": {
|
||||
"limitExpr": "stocknum",
|
||||
"marketCapLowerExpr": "3",
|
||||
"marketCapUpperExpr": "28",
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10"
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10",
|
||||
"currentDayPrecomputedFactors": true,
|
||||
"candidateSymbolsByDate": {
|
||||
"2025-01-02": ["600000.sh", "000001.SZ"],
|
||||
"2025-01-03": []
|
||||
}
|
||||
},
|
||||
"trading": {
|
||||
"refreshRateExpr": "year >= 2024 ? 5 : 20",
|
||||
@@ -2875,10 +3235,25 @@ mod tests {
|
||||
assert_eq!(cfg.signal_symbol, "000852.SH");
|
||||
assert_eq!(cfg.selection_limit_expr, "stocknum");
|
||||
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!(!cfg.rotation_enabled);
|
||||
assert!(cfg.daily_top_up_enabled);
|
||||
assert!(cfg.retry_empty_rebalance);
|
||||
assert!(cfg.current_day_precomputed_factors);
|
||||
assert_eq!(
|
||||
cfg.candidate_symbols_by_date[&NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()],
|
||||
BTreeSet::from(["000001.SZ".to_string(), "600000.SH".to_string()])
|
||||
);
|
||||
assert!(
|
||||
cfg.candidate_symbols_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty()
|
||||
);
|
||||
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
|
||||
assert!(!cfg.calendar_rebalance_interval);
|
||||
assert_eq!(cfg.explicit_actions.len(), 1);
|
||||
@@ -2888,6 +3263,67 @@ 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]
|
||||
fn parses_and_rejects_invalid_position_target_rules() {
|
||||
let spec = serde_json::json!({
|
||||
"strategyId": "factor_reduction",
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"positionTargetRules": [{
|
||||
"whenExpr": "factors[\"reduce_signal\"] == 1",
|
||||
"remainingPositionBps": 5000,
|
||||
"reason": "factor_reduce_position"
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("position rule config");
|
||||
assert_eq!(
|
||||
cfg.position_target_rules,
|
||||
vec![PlatformPositionTargetRule {
|
||||
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
||||
remaining_position_bps: 5000,
|
||||
reason: "factor_reduce_position".to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
let invalid_bps = serde_json::json!({
|
||||
"runtimeExpressions": {"risk": {"positionTargetRules": [{
|
||||
"whenExpr": "true",
|
||||
"remainingPositionBps": 10000
|
||||
}]}}
|
||||
});
|
||||
assert!(
|
||||
platform_expr_config_from_value("", "", &invalid_bps)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("must be between 0 and 9999")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_minute_stage_schedule_and_initial_subscriptions() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -3192,7 +3628,9 @@ mod tests {
|
||||
"targetPortfolioDaily": true,
|
||||
"rebalanceExistingPositions": true,
|
||||
"holdUntilExit": true,
|
||||
"releaseSlotOnExitSignal": true
|
||||
"releaseSlotOnExitSignal": true,
|
||||
"redistributeTargetWeightsAfterExit": true,
|
||||
"reenterExitedTargets": true
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3206,6 +3644,8 @@ mod tests {
|
||||
assert!(cfg.rebalance_existing_positions);
|
||||
assert!(cfg.hold_until_exit_enabled);
|
||||
assert!(cfg.release_slot_on_exit_signal);
|
||||
assert!(cfg.redistribute_target_weights_after_exit);
|
||||
assert!(cfg.reenter_exited_targets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3287,6 +3727,9 @@ mod tests {
|
||||
"rejectUpperLimitBuy": false,
|
||||
"rejectLowerLimitSell": false,
|
||||
"forbidSameDayRebuyAfterSell": true,
|
||||
"maxOrderQuantity": 8000,
|
||||
"maxOrderNotional": 2000000,
|
||||
"maxSymbolPosition": 12000,
|
||||
"blacklist": [" 600000.SH ", ""],
|
||||
"volumeLimitEnabled": true,
|
||||
"volumePercent": 0.1,
|
||||
@@ -3328,6 +3771,18 @@ mod tests {
|
||||
);
|
||||
assert!(cfg.risk_config.trading_constraints.volume_limit_enabled);
|
||||
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
||||
assert_eq!(
|
||||
cfg.risk_config.trading_constraints.max_order_quantity,
|
||||
8000.0
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.risk_config.trading_constraints.max_order_notional,
|
||||
2_000_000.0
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.risk_config.trading_constraints.max_symbol_position,
|
||||
12_000.0
|
||||
);
|
||||
assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
|
||||
assert_eq!(
|
||||
cfg.risk_config
|
||||
@@ -3350,6 +3805,22 @@ mod tests {
|
||||
assert!(cfg.quote_quantity_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_shared_order_limits() {
|
||||
for (field, value) in [
|
||||
("maxOrderQuantity", 0.0),
|
||||
("maxOrderNotional", -1.0),
|
||||
("maxSymbolPosition", -0.5),
|
||||
] {
|
||||
let spec = serde_json::json!({
|
||||
"execution": { "riskPolicy": { field: value } }
|
||||
});
|
||||
let error = platform_expr_config_from_value("", "", &spec)
|
||||
.expect_err("invalid shared order limit must fail");
|
||||
assert!(error.to_string().contains("riskPolicy"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_limit_does_not_enable_quote_quantity_limit_for_minute_last() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -3368,6 +3839,24 @@ mod tests {
|
||||
assert!(!cfg.quote_quantity_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_close_respects_explicitly_disabled_liquidity_limit() {
|
||||
let spec = serde_json::json!({
|
||||
"execution": {
|
||||
"matchingType": "current_bar_close",
|
||||
"volumeLimit": true,
|
||||
"liquidityLimit": false,
|
||||
"volumePercent": 0.25
|
||||
}
|
||||
});
|
||||
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
|
||||
assert!(cfg.risk_config.trading_constraints.volume_limit_enabled);
|
||||
assert!(!cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
||||
assert!(!cfg.quote_quantity_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_st_and_star_st_risk_policy_switches_into_platform_config() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -3482,6 +3971,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_strategy_risk_policy_fields_before_deserialization() {
|
||||
let error = validate_strategy_risk_policy_fields(&serde_json::json!({
|
||||
"execution": {
|
||||
"riskPolicy": {"rejectStBuy": true, "typoRiskSwitch": false}
|
||||
}
|
||||
}))
|
||||
.expect_err("unsupported risk policy fields must fail closed");
|
||||
assert!(error.contains("typoRiskSwitch"), "{error}");
|
||||
validate_strategy_risk_policy_fields(&serde_json::json!({
|
||||
"execution": {"riskPolicy": {"maxOrderQuantity": 1000}}
|
||||
}))
|
||||
.expect("supported strategy risk fields should pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_equivalent_risk_policy_alias_values() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -3508,6 +4012,25 @@ mod tests {
|
||||
assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_scoped_blacklist_context_in_runtime_risk_policy() {
|
||||
let spec = serde_json::json!({
|
||||
"execution": {
|
||||
"riskPolicy": {
|
||||
"account_blacklisted_instruments": {
|
||||
"2075773": ["000001.SZ"]
|
||||
},
|
||||
"strategyBlacklistedInstruments": {
|
||||
"live-gt-2075773-20260829": ["600000.SH"]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
platform_expr_config_from_value("105", "932000.CSI", &spec)
|
||||
.expect("scoped blacklist context is part of the shared runtime contract");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_conflicting_risk_policy_alias_values() {
|
||||
let bool_conflict = serde_json::json!({
|
||||
@@ -3820,6 +4343,34 @@ mod tests {
|
||||
assert_eq!(cfg.stock_long_ma_days, 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_expression_parses_dated_position_exposure_schedule() {
|
||||
let spec = serde_json::json!({
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"exposureExpr": "1.0",
|
||||
"positionExposureSchedule": [
|
||||
{"effectiveDate": "2026-08-14", "targetExposureBps": 6451},
|
||||
{"effective_date": "2026-08-20", "effective_bps": 3225}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||
assert_eq!(
|
||||
cfg.position_exposure_schedule[&NaiveDate::from_ymd_opt(2026, 8, 14).unwrap()],
|
||||
0.6451,
|
||||
);
|
||||
assert_eq!(cfg.position_exposure_schedule.len(), 2);
|
||||
|
||||
let invalid = serde_json::json!({
|
||||
"runtimeExpressions": {"risk": {"positionExposureSchedule": [
|
||||
{"effectiveDate": "2026-08-14", "targetExposureBps": 10001}
|
||||
]}}
|
||||
});
|
||||
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -3911,6 +4462,20 @@ mod tests {
|
||||
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_rotation_keeps_every_declared_clock_not_only_the_last_one() {
|
||||
use crate::Strategy;
|
||||
let literal=serde_json::to_string(&serde_json::json!({"template":"session_event","session_event":"INTRADAY_VOLUME_SPIKE","parameters":{}}).to_string()).unwrap();
|
||||
let mut spec=serde_json::json!({"rebalance":{"tradeTimes":["09:35","10:40","14:59"]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"14:59"},"trading":{"rotationEnabled":true,"buyFilterExpr":format!("pattern_signal({literal})")}},"execution":{"matchingType":"minute_last"}});
|
||||
let config=platform_expr_config_from_value("session","000300.SH",&spec).unwrap();
|
||||
assert_eq!(config.session_event_times.len(),3);
|
||||
let strategy=crate::PlatformExprStrategy::new(config);
|
||||
assert_eq!(strategy.schedule_rules().len(),3);
|
||||
assert_eq!(strategy.decision_quote_times().len(),3);
|
||||
spec["rebalance"]["tradeTimes"]=serde_json::json!([]);
|
||||
assert!(platform_expr_config_from_value("session","000300.SH",&spec).unwrap_err().to_string().contains("explicit_trade_times"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -4042,6 +4607,27 @@ mod tests {
|
||||
assert_eq!(control.cooldown_trading_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_loss_contract_is_explicit_and_validated() {
|
||||
let spec = serde_json::json!({"runtimeExpressions":{"risk":{"portfolioLossControl":{
|
||||
"enabled":true,"lookback":20,"lossTrigger":0.05,"floorExposure":0.2,"cooldownTradingDays":10
|
||||
}}}});
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).unwrap();
|
||||
assert_eq!(cfg.portfolio_loss_control.unwrap(), PortfolioLossConfig {
|
||||
lookback:20, loss_trigger:0.05, floor_exposure:0.2, cooldown_trading_days:10,
|
||||
});
|
||||
for (field, value) in [("lookback", serde_json::json!(0)),
|
||||
("lossTrigger", serde_json::json!(0.01)), ("floorExposure", serde_json::json!(1.1)),
|
||||
("cooldownTradingDays", serde_json::json!(0))] {
|
||||
let mut invalid = spec.clone();
|
||||
invalid["runtimeExpressions"]["risk"]["portfolioLossControl"][field] = value;
|
||||
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
|
||||
}
|
||||
let mut missing = spec.clone();
|
||||
missing["runtimeExpressions"]["risk"]["portfolioLossControl"].as_object_mut().unwrap().remove("lossTrigger");
|
||||
assert!(platform_expr_config_from_value("", "", &missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_portfolio_drawdown_control() {
|
||||
let spec = serde_json::json!({
|
||||
@@ -4080,6 +4666,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]
|
||||
fn rejects_unknown_stop_take_reference_price_mode() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct PositionLot {
|
||||
pub struct Position {
|
||||
pub symbol: String,
|
||||
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.
|
||||
pub average_cost: f64,
|
||||
pub last_price: f64,
|
||||
@@ -86,6 +88,7 @@ impl Position {
|
||||
Self {
|
||||
symbol: symbol.into(),
|
||||
quantity: 0,
|
||||
average_price: 0.0,
|
||||
average_cost: 0.0,
|
||||
last_price: 0.0,
|
||||
realized_pnl: FixedMoney::ZERO,
|
||||
@@ -127,6 +130,7 @@ impl Position {
|
||||
}
|
||||
|
||||
let previous_quantity = self.quantity;
|
||||
let previous_average_price = self.average_price;
|
||||
let previous_average_cost = self.average_cost;
|
||||
let gross_amount = fixed_money_or_panic(
|
||||
execution_price * quantity as f64,
|
||||
@@ -146,6 +150,18 @@ impl Position {
|
||||
.day_buy_value
|
||||
.checked_add(gross_amount)
|
||||
.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
|
||||
&& previous_average_cost.is_finite()
|
||||
&& previous_average_cost > 0.0
|
||||
@@ -186,6 +202,7 @@ impl Position {
|
||||
let mut remaining_proceeds = total_proceeds;
|
||||
let mut realized = FixedMoney::ZERO;
|
||||
let mut realized_entry = FixedMoney::ZERO;
|
||||
let average_price_before_sell = self.average_price;
|
||||
let average_cost_before_sell = self.average_cost;
|
||||
|
||||
while remaining > 0 {
|
||||
@@ -250,11 +267,19 @@ impl Position {
|
||||
.checked_add(total_proceeds)
|
||||
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
||||
if self.quantity == 0 {
|
||||
self.average_price = 0.0;
|
||||
self.recalculate_average_cost();
|
||||
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
||||
self.average_cost = average_cost_before_sell;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
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;
|
||||
} else {
|
||||
self.recalculate_average_cost();
|
||||
}
|
||||
}
|
||||
self.refresh_day_pnl();
|
||||
Ok(realized.to_f64())
|
||||
@@ -298,6 +323,13 @@ impl Position {
|
||||
.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 {
|
||||
self.realized_pnl.to_f64() + self.unrealized_pnl()
|
||||
}
|
||||
@@ -421,9 +453,12 @@ impl Position {
|
||||
}
|
||||
|
||||
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
||||
let Some(avg_price) = self.average_entry_price() else {
|
||||
return None;
|
||||
};
|
||||
let avg_price = self
|
||||
.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 {
|
||||
None
|
||||
} else {
|
||||
@@ -503,6 +538,7 @@ impl Position {
|
||||
if adjust_cost_basis {
|
||||
self.average_cost -= dividend_per_share;
|
||||
}
|
||||
self.average_price -= dividend_per_share;
|
||||
self.last_price -= dividend_per_share;
|
||||
self.day_dividend_cash = self
|
||||
.day_dividend_cash
|
||||
@@ -545,6 +581,11 @@ impl Position {
|
||||
self.lots = scaled_lots;
|
||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||
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 {
|
||||
self.average_cost /= ratio;
|
||||
} else {
|
||||
@@ -1128,8 +1169,11 @@ impl PortfolioState {
|
||||
.map(|position| {
|
||||
let market_value = position.market_value();
|
||||
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)
|
||||
.or_else(|| position.average_entry_price())
|
||||
.unwrap_or(position.average_cost);
|
||||
HoldingSummary {
|
||||
date,
|
||||
@@ -1143,7 +1187,7 @@ impl PortfolioState {
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
unrealized_pnl: position.unrealized_entry_pnl(),
|
||||
unrealized_pnl: position.unrealized_average_price_pnl(),
|
||||
realized_pnl: position.realized_entry_pnl(),
|
||||
pnl: position.entry_pnl(),
|
||||
trading_pnl: position.trading_pnl,
|
||||
@@ -1181,6 +1225,7 @@ impl PortfolioState {
|
||||
|
||||
let old_quantity = old_position.quantity;
|
||||
let last_price = old_position.last_price;
|
||||
let old_average_price = old_position.average_price;
|
||||
let old_average_cost = old_position.average_cost;
|
||||
let realized_pnl = old_position.realized_pnl;
|
||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||
@@ -1218,6 +1263,7 @@ impl PortfolioState {
|
||||
.entry(new_symbol.to_string())
|
||||
.or_insert_with(|| Position::new(new_symbol));
|
||||
let successor_quantity_before = successor.quantity;
|
||||
let successor_average_price_before = successor.average_price;
|
||||
let successor_average_cost_before = successor.average_cost;
|
||||
successor.lots.extend(converted_lots);
|
||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||
@@ -1232,6 +1278,30 @@ impl PortfolioState {
|
||||
if converted_last_price > 0.0 {
|
||||
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()
|
||||
&& old_average_cost > 0.0
|
||||
&& ratio.is_finite()
|
||||
@@ -1375,8 +1445,10 @@ mod tests {
|
||||
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
|
||||
|
||||
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_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!((position.unrealized_pnl() - 95.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);
|
||||
|
||||
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.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");
|
||||
|
||||
assert_eq!(position.quantity, 100);
|
||||
assert!((position.average_price - 18.94711428571429).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");
|
||||
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_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||
|
||||
position.buy(date, 100, 5.0);
|
||||
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_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);
|
||||
|
||||
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_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
|
||||
assert!((position.last_price - 45.85).abs() < 1e-12);
|
||||
@@ -1571,6 +1648,7 @@ mod tests {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1659,6 +1737,7 @@ mod tests {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::collections::BTreeSet;
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::OrderSide;
|
||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
||||
use crate::instrument::Instrument;
|
||||
use crate::portfolio::Position;
|
||||
@@ -77,6 +78,13 @@ impl Default for StaticRiskRuleConfig {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TradingConstraintConfig {
|
||||
/// Shared execution limits. These fields intentionally use the same
|
||||
/// names and defaults as the FIDC trading-core RiskLimits contract so a
|
||||
/// strategy cannot appear protected in paper/live while being unlimited
|
||||
/// in a backtest.
|
||||
pub max_order_quantity: f64,
|
||||
pub max_order_notional: f64,
|
||||
pub max_symbol_position: f64,
|
||||
pub volume_limit_enabled: bool,
|
||||
pub volume_percent: f64,
|
||||
pub liquidity_limit_enabled: bool,
|
||||
@@ -91,6 +99,9 @@ pub struct TradingConstraintConfig {
|
||||
impl Default for TradingConstraintConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_order_quantity: 1_000_000.0,
|
||||
max_order_notional: 100_000_000.0,
|
||||
max_symbol_position: 10_000_000.0,
|
||||
volume_limit_enabled: true,
|
||||
volume_percent: 0.25,
|
||||
liquidity_limit_enabled: true,
|
||||
@@ -386,7 +397,7 @@ impl ChinaAShareRiskControl {
|
||||
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
|
||||
RiskCheckScope::Sell => false,
|
||||
};
|
||||
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) {
|
||||
if reject_kcb && candidate.is_kcb {
|
||||
return Some("kcb");
|
||||
}
|
||||
let reject_bjse = match scope {
|
||||
@@ -402,7 +413,10 @@ impl ChinaAShareRiskControl {
|
||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
||||
RiskCheckScope::Sell => false,
|
||||
};
|
||||
if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) {
|
||||
if reject_one_yuan
|
||||
&& (candidate.is_one_yuan
|
||||
|| (market.day_open.is_finite() && market.day_open > 0.0 && market.day_open <= 1.0))
|
||||
{
|
||||
return Some("one_yuan");
|
||||
}
|
||||
if Self::missing_risk_state_rejected(candidate, config, scope) {
|
||||
@@ -479,6 +493,36 @@ impl ChinaAShareRiskControl {
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply the shared quantity/notional/position limits at the same stage
|
||||
/// as paper/live `RiskLimits`. Static instrument rules remain in the
|
||||
/// side-specific methods above; this helper only checks order sizing and
|
||||
/// never changes selection semantics.
|
||||
pub fn order_size_rejection_reason_with_config(
|
||||
side: OrderSide,
|
||||
requested_quantity: u32,
|
||||
current_position_quantity: u32,
|
||||
check_price: f64,
|
||||
config: &FidcRiskControlConfig,
|
||||
) -> Option<&'static str> {
|
||||
let limits = &config.trading_constraints;
|
||||
if (requested_quantity as f64) > limits.max_order_quantity {
|
||||
return Some("quantity exceeds max_order_quantity");
|
||||
}
|
||||
if check_price.is_finite()
|
||||
&& check_price > 0.0
|
||||
&& (requested_quantity as f64) * check_price > limits.max_order_notional
|
||||
{
|
||||
return Some("notional exceeds max_order_notional");
|
||||
}
|
||||
if side == OrderSide::Buy
|
||||
&& (current_position_quantity as f64) + (requested_quantity as f64)
|
||||
> limits.max_symbol_position
|
||||
{
|
||||
return Some("position exceeds max_symbol_position");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn sell_rejection_reason(
|
||||
date: NaiveDate,
|
||||
candidate: &CandidateEligibility,
|
||||
@@ -519,7 +563,9 @@ impl ChinaAShareRiskControl {
|
||||
// lifecycle fact must still protect the sell path. Otherwise a
|
||||
// `inactive_or_delisted` candidate could fall through to a synthetic
|
||||
// sell price and violate the unresolved-delisted holding contract.
|
||||
if let Some(reason) = candidate_active_status_rejection(candidate, config, RiskCheckScope::Sell) {
|
||||
if let Some(reason) =
|
||||
candidate_active_status_rejection(candidate, config, RiskCheckScope::Sell)
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
|
||||
@@ -554,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 {
|
||||
let normalized = symbol.trim().to_ascii_uppercase();
|
||||
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
|
||||
@@ -963,6 +1004,24 @@ mod tests {
|
||||
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]
|
||||
fn st_and_star_st_filters_are_independent() {
|
||||
let date = d(2025, 1, 2);
|
||||
@@ -1093,6 +1152,7 @@ mod tests {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.symbol = "688506.SH".to_string();
|
||||
candidate.is_kcb = true;
|
||||
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
||||
let market = market(date, 6.27, 5.63);
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
@@ -1426,4 +1486,53 @@ mod tests {
|
||||
assert_eq!(enabled_reason, Some("lower_limit"));
|
||||
assert_eq!(configured_reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_order_size_limits_apply_to_both_sides_and_buy_position() {
|
||||
let mut config = FidcRiskControlConfig::default();
|
||||
config.trading_constraints.max_order_quantity = 500.0;
|
||||
config.trading_constraints.max_order_notional = 5_000.0;
|
||||
config.trading_constraints.max_symbol_position = 800.0;
|
||||
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||
OrderSide::Buy,
|
||||
600,
|
||||
0,
|
||||
5.0,
|
||||
&config,
|
||||
),
|
||||
Some("quantity exceeds max_order_quantity")
|
||||
);
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||
OrderSide::Sell,
|
||||
400,
|
||||
10_000,
|
||||
20.0,
|
||||
&config,
|
||||
),
|
||||
Some("notional exceeds max_order_notional")
|
||||
);
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||
OrderSide::Buy,
|
||||
300,
|
||||
600,
|
||||
5.0,
|
||||
&config,
|
||||
),
|
||||
Some("position exceeds max_symbol_position")
|
||||
);
|
||||
assert_eq!(
|
||||
ChinaAShareRiskControl::order_size_rejection_reason_with_config(
|
||||
OrderSide::Sell,
|
||||
200,
|
||||
10_000,
|
||||
5.0,
|
||||
&config,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,18 +284,27 @@ mod tests {
|
||||
|
||||
assert!(scheduler.is_due_on(d(2025, 1, 30), &daily));
|
||||
assert!(scheduler.is_due_on(d(2025, 1, 31), &daily));
|
||||
assert!(scheduler.triggered_rules_at(
|
||||
d(2025, 1, 30),
|
||||
ScheduleStage::OnDay,
|
||||
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()),
|
||||
std::slice::from_ref(&daily),
|
||||
).len() == 1);
|
||||
assert!(scheduler.triggered_rules_at(
|
||||
d(2025, 1, 30),
|
||||
ScheduleStage::OnDay,
|
||||
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
|
||||
std::slice::from_ref(&daily),
|
||||
).is_empty());
|
||||
assert!(
|
||||
scheduler
|
||||
.triggered_rules_at(
|
||||
d(2025, 1, 30),
|
||||
ScheduleStage::OnDay,
|
||||
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()),
|
||||
std::slice::from_ref(&daily),
|
||||
)
|
||||
.len()
|
||||
== 1
|
||||
);
|
||||
assert!(
|
||||
scheduler
|
||||
.triggered_rules_at(
|
||||
d(2025, 1, 30),
|
||||
ScheduleStage::OnDay,
|
||||
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
|
||||
std::slice::from_ref(&daily),
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Completed, same-session minute events. These bars never become execution quotes.
|
||||
use crate::{
|
||||
daily_patterns::{PatternResult, PatternSpec},
|
||||
factor_events::{Expr, Frame},
|
||||
};
|
||||
use chrono::{FixedOffset, NaiveDateTime, NaiveTime, TimeZone, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const CONTRACT: &str = "fidc_completed_session_events_v1";
|
||||
pub const EVENTS: &[&str] = &[
|
||||
"PRICE_CROSS_VWAP_UP",
|
||||
"PRICE_CROSS_VWAP_DOWN",
|
||||
"INTRADAY_HIGH_BREAKOUT",
|
||||
"INTRADAY_LOW_BREAKDOWN",
|
||||
"OPENING_RANGE_BREAKOUT_UP",
|
||||
"OPENING_RANGE_BREAKOUT_DOWN",
|
||||
"INTRADAY_VOLUME_SPIKE",
|
||||
"MORNING_HIGH_BREAKOUT",
|
||||
"MORNING_LOW_BREAKDOWN",
|
||||
"AFTERNOON_MOMENTUM_UP",
|
||||
"AFTERNOON_MOMENTUM_DOWN",
|
||||
"LATE_SESSION_STRENGTH",
|
||||
"LATE_SESSION_WEAKNESS",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MinuteBar {
|
||||
pub symbol: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub available_at: NaiveDateTime,
|
||||
pub open: f64,
|
||||
pub high: f64,
|
||||
pub low: f64,
|
||||
pub close: f64,
|
||||
pub volume: f64,
|
||||
pub amount: f64,
|
||||
}
|
||||
pub type BarStore = Arc<BTreeMap<(chrono::NaiveDate, String), Vec<MinuteBar>>>;
|
||||
pub fn bar_store(bars: Vec<MinuteBar>) -> Result<BarStore, String> {
|
||||
let mut groups = BTreeMap::<(chrono::NaiveDate, String), Vec<MinuteBar>>::new();
|
||||
for bar in bars {
|
||||
groups
|
||||
.entry((bar.timestamp.date(), bar.symbol.clone()))
|
||||
.or_default()
|
||||
.push(bar);
|
||||
}
|
||||
for rows in groups.values_mut() {
|
||||
rows.sort_by_key(|r| r.timestamp);
|
||||
if rows
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].timestamp == pair[1].timestamp)
|
||||
{
|
||||
return Err("duplicate_completed_minute_bar".into());
|
||||
}
|
||||
}
|
||||
Ok(Arc::new(groups))
|
||||
}
|
||||
fn f(name: &str) -> Expr {
|
||||
Expr::Field { name: name.into() }
|
||||
}
|
||||
fn n(value: f64) -> Expr {
|
||||
Expr::Number { value }
|
||||
}
|
||||
fn op(name: &str, args: Vec<Expr>, window: Option<usize>) -> Expr {
|
||||
Expr::Operator {
|
||||
name: name.into(),
|
||||
args,
|
||||
window,
|
||||
}
|
||||
}
|
||||
fn time(minutes: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(minutes / 60, minutes % 60, 0).unwrap()
|
||||
}
|
||||
|
||||
pub fn is_regular_label(t: NaiveTime) -> bool {
|
||||
t.second() == 0 && (time(570) <= t && t <= time(690) || time(780) < t && t <= time(900))
|
||||
}
|
||||
|
||||
pub fn expression(event: &str, p: &BTreeMap<String, Value>) -> Result<Expr, String> {
|
||||
let cross = |up: bool, a: Expr, b: Expr| {
|
||||
op(
|
||||
if up { "CROSS_ABOVE" } else { "CROSS_BELOW" },
|
||||
vec![a, b],
|
||||
None,
|
||||
)
|
||||
};
|
||||
Ok(match event {
|
||||
"PRICE_CROSS_VWAP_UP" => cross(true, f("close"), f("session_vwap")),
|
||||
"PRICE_CROSS_VWAP_DOWN" => cross(false, f("close"), f("session_vwap")),
|
||||
"INTRADAY_HIGH_BREAKOUT" => op(
|
||||
"GT",
|
||||
vec![
|
||||
f("close"),
|
||||
op("LAG", vec![op("CUMMAX", vec![f("high")], None)], Some(1)),
|
||||
],
|
||||
None,
|
||||
),
|
||||
"INTRADAY_LOW_BREAKDOWN" => op(
|
||||
"LT",
|
||||
vec![
|
||||
f("close"),
|
||||
op("LAG", vec![op("CUMMIN", vec![f("low")], None)], Some(1)),
|
||||
],
|
||||
None,
|
||||
),
|
||||
"OPENING_RANGE_BREAKOUT_UP" => cross(true, f("close"), f("opening_high")),
|
||||
"OPENING_RANGE_BREAKOUT_DOWN" => cross(false, f("close"), f("opening_low")),
|
||||
"MORNING_HIGH_BREAKOUT" => cross(true, f("close"), f("morning_high")),
|
||||
"MORNING_LOW_BREAKDOWN" => cross(false, f("close"), f("morning_low")),
|
||||
"AFTERNOON_MOMENTUM_UP" => cross(true, f("afternoon_return"), n(0.)),
|
||||
"AFTERNOON_MOMENTUM_DOWN" => cross(false, f("afternoon_return"), n(0.)),
|
||||
"LATE_SESSION_STRENGTH" => cross(true, f("late_return"), n(0.)),
|
||||
"LATE_SESSION_WEAKNESS" => cross(false, f("late_return"), n(0.)),
|
||||
"INTRADAY_VOLUME_SPIKE" => op(
|
||||
"GTE",
|
||||
vec![
|
||||
f("volume"),
|
||||
op(
|
||||
"MUL",
|
||||
vec![
|
||||
op(
|
||||
"LAG",
|
||||
vec![op(
|
||||
"ROLLING_MEAN",
|
||||
vec![f("volume")],
|
||||
Some(p["volume_window"].as_u64().unwrap() as usize),
|
||||
)],
|
||||
Some(1),
|
||||
),
|
||||
n(p["volume_multiple"].as_f64().unwrap()),
|
||||
],
|
||||
None,
|
||||
),
|
||||
],
|
||||
None,
|
||||
),
|
||||
_ => return Err("session_event_not_registered".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
spec: &PatternSpec,
|
||||
symbol: &str,
|
||||
bars: &[MinuteBar],
|
||||
decision: NaiveDateTime,
|
||||
) -> Result<PatternResult, String> {
|
||||
let mut result = PatternResult {
|
||||
symbol: symbol.into(),
|
||||
name: None,
|
||||
matched: false,
|
||||
score: None,
|
||||
checks: vec![],
|
||||
values: json!({}),
|
||||
anchor: Value::Null,
|
||||
exclusion: None,
|
||||
};
|
||||
if bars.is_empty() {
|
||||
return Err(format!(
|
||||
"session_source_missing: {symbol} {}",
|
||||
decision.date()
|
||||
));
|
||||
}
|
||||
let visible = bars
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
b.timestamp.date() == decision.date()
|
||||
&& b.timestamp < decision
|
||||
&& b.available_at <= decision
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if visible.is_empty() {
|
||||
result.exclusion = Some(json!({"reason":"session_before_first_completed_bar"}));
|
||||
return Ok(result);
|
||||
}
|
||||
let last = visible.last().unwrap().timestamp;
|
||||
let expected = (570..=690)
|
||||
.chain(781..=900)
|
||||
.map(|m| decision.date().and_time(time(m)))
|
||||
.filter(|t| *t < decision)
|
||||
.last();
|
||||
if expected != Some(last) {
|
||||
return Err(format!(
|
||||
"session_latest_bar_missing: {symbol} expected={expected:?} actual={last}"
|
||||
));
|
||||
}
|
||||
let mut indexed = BTreeMap::new();
|
||||
for b in &visible {
|
||||
if b.symbol != symbol
|
||||
|| !is_regular_label(b.timestamp.time())
|
||||
|| b.available_at < b.timestamp
|
||||
|| [b.open, b.high, b.low, b.close, b.volume, b.amount]
|
||||
.iter()
|
||||
.any(|v| !v.is_finite())
|
||||
|| b.low <= 0.
|
||||
|| b.open <= 0.
|
||||
|| b.close <= 0.
|
||||
|| b.high < b.open.max(b.close)
|
||||
|| b.low > b.open.min(b.close)
|
||||
|| b.volume < 0.
|
||||
|| b.amount < 0.
|
||||
|| indexed.insert(b.timestamp, b).is_some()
|
||||
{
|
||||
return Err(format!("session_bar_invalid: {symbol} {}", b.timestamp));
|
||||
}
|
||||
}
|
||||
for minute in (571..=690).chain(781..=900) {
|
||||
let stamp = decision.date().and_time(time(minute));
|
||||
if stamp <= last && !indexed.contains_key(&stamp) {
|
||||
return Err(format!(
|
||||
"session_bar_gap: {symbol} {stamp}; no filling or calendar compression"
|
||||
));
|
||||
}
|
||||
}
|
||||
let opening_end = time(570 + spec.n("opening_minutes") as u32);
|
||||
let (mut volume, mut amount) = (0., 0.);
|
||||
let (mut opening_high, mut opening_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let (mut morning_high, mut morning_low) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let (mut morning_close, mut late_close) = (None, None);
|
||||
let mut fields: BTreeMap<String, Vec<Option<f64>>> = [
|
||||
"open",
|
||||
"high",
|
||||
"low",
|
||||
"close",
|
||||
"volume",
|
||||
"amount",
|
||||
"session_vwap",
|
||||
"opening_high",
|
||||
"opening_low",
|
||||
"morning_high",
|
||||
"morning_low",
|
||||
"afternoon_return",
|
||||
"late_return",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|s| (s.into(), vec![]))
|
||||
.collect();
|
||||
let mut timestamps = vec![];
|
||||
let mut available_at = vec![];
|
||||
let zone = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
for b in indexed.values() {
|
||||
let t = b.timestamp.time();
|
||||
volume += b.volume;
|
||||
amount += b.amount;
|
||||
if t <= opening_end {
|
||||
opening_high = opening_high.max(b.high);
|
||||
opening_low = opening_low.min(b.low);
|
||||
}
|
||||
if t <= time(690) {
|
||||
morning_high = morning_high.max(b.high);
|
||||
morning_low = morning_low.min(b.low);
|
||||
}
|
||||
if t == time(690) {
|
||||
morning_close = Some(b.close);
|
||||
}
|
||||
if t == time(870) {
|
||||
late_close = Some(b.close);
|
||||
}
|
||||
for (name, value) in [
|
||||
("open", Some(b.open)),
|
||||
("high", Some(b.high)),
|
||||
("low", Some(b.low)),
|
||||
("close", Some(b.close)),
|
||||
("volume", Some(b.volume)),
|
||||
("amount", Some(b.amount)),
|
||||
("session_vwap", (volume > 0.).then_some(amount / volume)),
|
||||
("opening_high", (t >= opening_end).then_some(opening_high)),
|
||||
("opening_low", (t >= opening_end).then_some(opening_low)),
|
||||
("morning_high", (t >= time(690)).then_some(morning_high)),
|
||||
("morning_low", (t >= time(690)).then_some(morning_low)),
|
||||
("afternoon_return", morning_close.map(|v| b.close / v - 1.)),
|
||||
("late_return", late_close.map(|v| b.close / v - 1.)),
|
||||
] {
|
||||
fields.get_mut(name).unwrap().push(value);
|
||||
}
|
||||
timestamps.push(zone.from_local_datetime(&b.timestamp).single().unwrap());
|
||||
available_at.push(zone.from_local_datetime(&b.available_at).single().unwrap());
|
||||
}
|
||||
let frame = Frame {
|
||||
symbol: symbol.into(),
|
||||
frequency: "1m".into(),
|
||||
decision_at: zone.from_local_datetime(&decision).single().unwrap(),
|
||||
timestamps,
|
||||
available_at,
|
||||
fields,
|
||||
};
|
||||
let event = spec
|
||||
.session_event
|
||||
.as_deref()
|
||||
.ok_or("session_event_id_required")?;
|
||||
let values = crate::factor_events::evaluate(&expression(event, &spec.parameters)?, &frame)?;
|
||||
let latest = values.values.last().copied().flatten();
|
||||
result.score = latest;
|
||||
result.matched = latest == Some(1.);
|
||||
result.values = json!({"session_event":event,"session_contract":CONTRACT,"expression":values,"signal_bar_end":last,"decision_at":decision,"bars":visible.len(),"bar_times":frame.timestamps.iter().map(|t|t.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<_>>(),"close":visible.last().unwrap().close,"session_return":visible.last().unwrap().close/visible.first().unwrap().open-1.,"price_policy":"same_session_raw_ohlcv"});
|
||||
if latest.is_none() {
|
||||
result.exclusion = Some(json!({"reason":"session_warmup_or_undefined"}));
|
||||
} else {
|
||||
result.checks.push(json!({"label":"分钟事件","actual":latest,"operator":"==","threshold":1,"passed":result.matched}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn spec(event: &str) -> PatternSpec {
|
||||
serde_json::from_value::<PatternSpec>(
|
||||
json!({"template":"session_event","session_event":event,"parameters":{}}),
|
||||
)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap()
|
||||
}
|
||||
fn bars() -> Vec<MinuteBar> {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
|
||||
(570..=690)
|
||||
.chain(781..=900)
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let timestamp = date.and_time(time(m));
|
||||
let price = 100. + (i % 17) as f64 / 10.;
|
||||
let volume = if i % 39 == 0 { 1000. } else { 100. };
|
||||
MinuteBar {
|
||||
symbol: "300395.SZ".into(),
|
||||
timestamp,
|
||||
available_at: timestamp,
|
||||
open: price,
|
||||
high: price + 0.1,
|
||||
low: price - 0.1,
|
||||
close: price,
|
||||
volume,
|
||||
amount: volume * price,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn all_thirteen_events_return_native_boolean_series() {
|
||||
let bars = bars();
|
||||
let decision = "2026-09-08T15:00:01".parse().unwrap();
|
||||
for event in EVENTS {
|
||||
let value = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
assert!(value.score.is_some(), "{event}");
|
||||
assert_eq!(value.values["expression"]["value_type"], "boolean");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn decision_uses_the_previous_completed_label_and_future_prices_do_not_rewrite() {
|
||||
let mut bars = bars();
|
||||
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||
for event in EVENTS {
|
||||
let before = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
for bar in &mut bars {
|
||||
if bar.timestamp >= decision {
|
||||
bar.open = 1000.;
|
||||
bar.close = 1000.;
|
||||
bar.high = 1001.;
|
||||
bar.low = 999.;
|
||||
}
|
||||
}
|
||||
let after = evaluate(&spec(event), "300395.SZ", &bars, decision).unwrap();
|
||||
assert_eq!(before.values, after.values);
|
||||
assert_eq!(after.values["signal_bar_end"], "2026-09-08T10:01:00");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gaps_and_stale_last_bars_do_not_become_false_or_repeated_signals() {
|
||||
let mut values = bars();
|
||||
let decision = "2026-09-08T10:02:00".parse().unwrap();
|
||||
values.retain(|r| r.timestamp.time() != time(600));
|
||||
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &values, decision)
|
||||
.unwrap_err()
|
||||
.contains("session_bar_gap"));
|
||||
let stale = bars()
|
||||
.into_iter()
|
||||
.filter(|r| r.timestamp.time() < time(601))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(evaluate(&spec(EVENTS[0]), "300395.SZ", &stale, decision)
|
||||
.unwrap_err()
|
||||
.contains("latest_bar_missing"));
|
||||
}
|
||||
#[test]
|
||||
fn opening_range_is_unavailable_before_the_range_has_completed() {
|
||||
let value = evaluate(
|
||||
&spec("OPENING_RANGE_BREAKOUT_UP"),
|
||||
"300395.SZ",
|
||||
&bars(),
|
||||
"2026-09-08T09:59:01".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(value.score, None);
|
||||
assert!(!value.matched);
|
||||
}
|
||||
}
|
||||
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StrategyDecision {
|
||||
pub buy_denials: BTreeMap<String, String>,
|
||||
pub rebalance: bool,
|
||||
pub target_weights: BTreeMap<String, f64>,
|
||||
pub exit_symbols: BTreeSet<String>,
|
||||
@@ -987,7 +988,20 @@ pub struct 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) {
|
||||
self.buy_denials.append(&mut other.buy_denials);
|
||||
self.rebalance |= other.rebalance;
|
||||
self.target_weights.append(&mut other.target_weights);
|
||||
self.exit_symbols.append(&mut other.exit_symbols);
|
||||
@@ -998,7 +1012,8 @@ impl StrategyDecision {
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.rebalance
|
||||
self.buy_denials.is_empty()
|
||||
&& !self.rebalance
|
||||
&& self.target_weights.is_empty()
|
||||
&& self.exit_symbols.is_empty()
|
||||
&& self.order_intents.is_empty()
|
||||
@@ -1214,6 +1229,42 @@ pub enum 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 {
|
||||
match self {
|
||||
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
|
||||
@@ -1569,6 +1620,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
if self.config.in_skip_window(ctx.decision_date) {
|
||||
self.last_gross_exposure = Some(0.0);
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||
@@ -1590,6 +1642,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
if message.contains("signal series insufficient") =>
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1765,6 +1818,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
self.last_gross_exposure = Some(gross_exposure);
|
||||
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance,
|
||||
target_weights,
|
||||
exit_symbols,
|
||||
@@ -2773,6 +2827,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
let lagged_execution = ctx.is_lagged_execution();
|
||||
if self.config.in_skip_window(signal_date) {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||
@@ -2803,6 +2858,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
if message.contains("insufficient benchmark") =>
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3013,6 +3069,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
];
|
||||
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols,
|
||||
@@ -3154,6 +3211,7 @@ mod tests {
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -119,8 +119,8 @@ pub struct StrategyAiOptimizeRequest {
|
||||
}
|
||||
|
||||
const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益。";
|
||||
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "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=true、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=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
|
||||
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "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=true, 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=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
|
||||
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "max_order_quantity=1000000、max_order_notional=100000000、max_symbol_position=10000000、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=true、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=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
|
||||
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "max_order_quantity=1000000, max_order_notional=100000000, max_symbol_position=10000000, 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=true, 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=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
|
||||
|
||||
pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
StrategyAiManual {
|
||||
@@ -250,7 +250,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "risk.policy / risk.blacklist".to_string(),
|
||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认的 ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;但源策略明确写出的业务选股排除属于策略本身,必须原样保留在 filter.stock_expr,并且不能反向修改冻结的 reject_*_selection 开关。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 max_order_quantity、max_order_notional、max_symbol_position,以及 ST/*ST、停牌、退市、新股、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量、流动性和交易成本等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;源策略明确写出的业务选股排除属于策略本身,必须原样保留,不能反向修改冻结的 reject_*_selection 开关;冻结的 `reject_*_selection` 值不得改变。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
|
||||
@@ -339,6 +339,7 @@ mod tests {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(0.01),
|
||||
effective_turnover_ratio: Some(0.01),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -144,6 +145,7 @@ fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -270,6 +272,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -280,6 +283,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -290,6 +294,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ use fidc_core::{
|
||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
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]
|
||||
fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
let first = d(2026, 1, 5);
|
||||
@@ -125,6 +311,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -135,6 +322,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
@@ -293,6 +481,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -303,6 +492,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
@@ -526,6 +716,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -536,6 +727,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -170,6 +171,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -180,6 +182,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -190,6 +193,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -200,6 +204,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -436,6 +441,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -446,6 +452,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -456,6 +463,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -78,6 +78,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -144,6 +145,7 @@ fn factor_row(
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors,
|
||||
}
|
||||
}
|
||||
@@ -293,6 +295,7 @@ impl Strategy for HookProbeStrategy {
|
||||
.borrow_mut()
|
||||
.push(format!("on_day:{}", ctx.execution_date));
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -332,6 +335,7 @@ impl Strategy for AuctionOrderStrategy {
|
||||
_ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -375,6 +379,7 @@ impl Strategy for FuturesOrderStrategy {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -718,6 +723,7 @@ impl Strategy for LimitCarryStrategy {
|
||||
}
|
||||
self.issued = true;
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -801,6 +807,7 @@ impl Strategy for UniverseDirectiveStrategy {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -842,6 +849,7 @@ impl Strategy for MinuteProbeStrategy {
|
||||
_ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -883,6 +891,7 @@ impl Strategy for MinuteProbeStrategy {
|
||||
}
|
||||
self.ordered = true;
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -985,6 +994,7 @@ impl Strategy for OrderInspectionStrategy {
|
||||
_ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1028,6 +1038,7 @@ impl Strategy for AccountFlowStrategy {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1137,6 +1148,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -1147,6 +1159,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -1263,18 +1276,24 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
||||
)
|
||||
.with_process_event_retention(ProcessEventRetention::Business);
|
||||
let compact_result = compact_engine.run().expect("compact backtest succeeds");
|
||||
assert!(compact_result
|
||||
.process_events
|
||||
.iter()
|
||||
.all(|event| event.kind.is_business_lifecycle()));
|
||||
assert!(compact_result
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == ProcessEventKind::OnDay));
|
||||
assert!(!compact_result
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading));
|
||||
assert!(
|
||||
compact_result
|
||||
.process_events
|
||||
.iter()
|
||||
.all(|event| event.kind.is_business_lifecycle())
|
||||
);
|
||||
assert!(
|
||||
compact_result
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == ProcessEventKind::OnDay)
|
||||
);
|
||||
assert!(
|
||||
!compact_result
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == ProcessEventKind::PreBeforeTrading)
|
||||
);
|
||||
assert_eq!(
|
||||
result.process_events[..18]
|
||||
.iter()
|
||||
@@ -1347,6 +1366,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1447,6 +1467,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2162,6 +2183,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2457,6 +2479,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -2625,6 +2648,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2868,6 +2892,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -2878,6 +2903,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3063,6 +3089,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3073,6 +3100,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3260,6 +3288,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3270,6 +3299,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3280,6 +3310,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3515,6 +3546,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3525,6 +3557,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3535,6 +3568,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3880,6 +3914,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3890,6 +3925,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
||||
pe_ttm: 12.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
]
|
||||
@@ -4034,6 +4070,7 @@ fn engine_exposes_current_process_context_to_strategies() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -4106,6 +4143,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date == d(2025, 5, 26) {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::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,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -119,6 +120,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -207,6 +209,7 @@ fn execute_single_value_order(
|
||||
&mut portfolio,
|
||||
data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -272,6 +275,7 @@ fn single_symbol_limit_price_data(
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -343,6 +347,7 @@ fn broker_executes_explicit_order_value_buy() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -395,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -495,6 +501,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -552,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -629,6 +637,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -681,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -757,6 +767,7 @@ fn broker_executes_target_shares_like_order_to() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -799,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -904,6 +916,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -914,6 +927,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
||||
pe_ttm: 14.0,
|
||||
turnover_ratio: Some(2.2),
|
||||
effective_turnover_ratio: Some(2.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -972,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1062,6 +1077,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
pe_ttm: 14.0,
|
||||
turnover_ratio: Some(2.2),
|
||||
effective_turnover_ratio: Some(2.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1129,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1205,6 +1222,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1243,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
||||
&mut percent_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1267,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
||||
&mut target_percent_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1329,6 +1349,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1368,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1435,6 +1457,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1474,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1538,6 +1562,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1576,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1653,6 +1679,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1692,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1755,6 +1783,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1796,6 +1825,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1862,6 +1892,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -1916,6 +1947,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1982,6 +2014,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2021,6 +2054,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2091,6 +2125,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2144,6 +2179,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2209,6 +2245,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2262,6 +2299,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2339,6 +2377,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2377,6 +2416,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2445,6 +2485,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2512,6 +2553,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2616,6 +2658,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2684,6 +2727,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2771,6 +2815,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -2864,6 +2909,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -2931,6 +2977,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -3011,6 +3058,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3093,6 +3141,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -3146,6 +3195,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3210,6 +3260,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -3263,6 +3314,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3365,6 +3417,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3375,6 +3428,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3432,6 +3486,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3558,6 +3613,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3568,6 +3624,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
pe_ttm: 10.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3626,6 +3683,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("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,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3754,6 +3813,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3808,6 +3868,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("000001.SZ".to_string(), 0.2),
|
||||
@@ -3926,6 +3987,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -3936,6 +3998,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -3990,6 +4053,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([
|
||||
("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,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -4103,6 +4168,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4166,6 +4232,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
||||
pe_ttm: 20.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -4207,6 +4274,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4272,6 +4340,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -4312,6 +4381,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4386,6 +4456,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -4444,6 +4515,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4526,6 +4598,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -4587,6 +4660,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4640,6 +4714,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4686,6 +4761,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::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,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
DailyFactorSnapshot {
|
||||
@@ -4788,6 +4865,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
},
|
||||
],
|
||||
@@ -4859,6 +4937,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -4899,6 +4978,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5825,6 +5905,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5863,6 +5944,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5907,6 +5989,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
||||
&mut value_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5932,6 +6015,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
||||
&mut percent_portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5968,6 +6052,7 @@ fn broker_cancels_open_order_by_order_id() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -5991,6 +6076,7 @@ fn broker_cancels_open_order_by_order_id() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -6038,6 +6124,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -6104,6 +6191,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
||||
pe_ttm: 15.0,
|
||||
turnover_ratio: Some(2.0),
|
||||
effective_turnover_ratio: Some(1.8),
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: BTreeMap::new(),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
@@ -6145,6 +6233,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
||||
&mut portfolio,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
|
||||
@@ -52,9 +52,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
.collect::<Vec<_>>();
|
||||
let mut quotes = Vec::with_capacity(day_count * bars_per_day);
|
||||
for date in &dates {
|
||||
let session_start = date
|
||||
.and_hms_opt(9, 30, 0)
|
||||
.expect("valid session start");
|
||||
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
|
||||
for offset in 0..bars_per_day {
|
||||
let timestamp = session_start + Duration::minutes(offset as i64);
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
|
||||
@@ -72,6 +72,7 @@ fn factor(
|
||||
pe_ttm: 18.0,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None,
|
||||
extra_factors: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 日的滚动估计并验证样本外表现,不得回填到拟合区间内。
|
||||
|
||||
旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。
|
||||
@@ -10,7 +10,7 @@ The roadmap focuses on making the engine complete enough for editable platform
|
||||
strategies, long-range A-share backtests, futures strategies, intraday order
|
||||
simulation, AI-generated strategy code, and service-level result downloads.
|
||||
|
||||
## Re-Audit Findings (2026-04-24)
|
||||
## Re-Audit Findings (2026-08-31)
|
||||
|
||||
The latest re-audit focused on the engine's execution model, account model,
|
||||
order lifecycle, data helper surface, analyzer output, extension hooks, and
|
||||
@@ -39,7 +39,8 @@ futures path. Confirmed aligned areas:
|
||||
| P0 | Futures intraday matching | Closed for daily/open/close, tick-price futures fills, and true multi-level order-book sweeping when optional `order_book_depth` data exists. L1-only data still uses the existing L1 matcher and is not inflated into fake depth. | Extend depth fields only if production vendors expose more levels or exchange-specific fields. |
|
||||
| P0 | Futures open-order lifecycle | Closed for futures pending limit orders, cross-day rematching, cancellation by id/symbol/all, and merged open-order runtime views. | Add more order status transitions only if UI requires extra intermediate event names. |
|
||||
| P0 | Combined multi-account NAV | Closed. `DailyEquityPoint`, progress events, and metrics use aggregate stock + futures initial cash and total equity. | None. |
|
||||
| P0 | Fixed-point execution money | Stock execution now freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows and account units. Market indicators and return statistics remain `f64` outside the execution boundary. | Migrate position cost/PnL and the standalone futures cash ledger only after independent artifact and performance A/B gates. |
|
||||
| P0 | Fixed-point execution money | Closed. Stock execution freezes fee rates once and uses signed micro-yuan `i128` for gross amount, commission, stamp tax, transfer fee, strict budget checks, cash, liabilities, management fees, external flows, account units, position lot cost and realized PnL. The standalone futures account uses the same fixed-point money boundary for cash, margin, transaction cost and daily PnL. Market indicators and return statistics remain `f64` outside the execution boundary. | None. |
|
||||
| P0 | Bounded minute-data processing | Closed for the engine data model. Intraday history uses a sorted date index and scans backward only until the requested bar count is satisfied. Daily minute processing consumes a borrowed timestamp-ordered k-way merge and does not clone/materialize the full selected quote day before event dispatch. | Keep Source Lake and service clients batch-streamed; do not reintroduce whole-window row materialization. |
|
||||
| P1 | Futures trading parameter data source | Closed for engine-side trading-parameter ingestion/resolution via `futures_trading_parameters.csv` or component data. | Add more exchange metadata columns only when source data exposes them. |
|
||||
| P1 | Futures transaction cost decider | Closed. `FuturesTransactionCostModel` calculates by-money/by-volume open/close/close-today costs from trading parameters. | None. |
|
||||
| P1 | Futures settlement price mode | Closed. Engine supports configurable settlement price mode and resolves settlement/prev-settlement from factor fields with close/prev_close fallback. | Add dedicated settlement columns if the storage layer later separates them from factors. |
|
||||
@@ -58,6 +59,10 @@ futures path. Confirmed aligned areas:
|
||||
- [x] Fine-grained daily and minute execution quote strategy entrypoints.
|
||||
- [x] Stock broker fee, budget and cash-ledger arithmetic uses a micro-yuan
|
||||
fixed-point execution primitive; one-micro over-budget orders fail.
|
||||
- [x] Stock position lots, realized/unrealized PnL, dividends and external cash
|
||||
flows preserve fixed-point value conservation.
|
||||
- [x] Futures cash, margin, transaction cost and daily realized/position PnL use
|
||||
the fixed-point ledger.
|
||||
- [x] Scheduled actions evaluated against explicit intraday times.
|
||||
- [x] `update_universe`, `subscribe`, and `unsubscribe`.
|
||||
- [x] Intraday subscription guards at strategy API level; intraday execution uses minute quote semantics.
|
||||
@@ -73,6 +78,9 @@ futures path. Confirmed aligned areas:
|
||||
- [x] Trading-date range, previous-date, and next-date helpers.
|
||||
- [x] Phase-aware minute history cursor semantics matching the active bar or
|
||||
intraday execution quote callback.
|
||||
- [x] Bounded intraday history lookup and borrowed minute quote streaming avoid
|
||||
full-history scans and full-day quote clones while preserving timestamp
|
||||
order and visibility boundaries.
|
||||
- [x] Suspension, ST, date-range price, active instrument, and instrument
|
||||
history helpers.
|
||||
- [x] Open-order status, unfilled quantity, final order lookup, average fill
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schemaVersion": "fidc-batched-current-rolling-rejection/v1",
|
||||
"measuredAt": "2026-09-05T02:38:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "004a46c",
|
||||
"revertCommit": "43b15b2098c427869a4a582b4b24325155b1370e",
|
||||
"restoredRunnerBinarySha256": "a4135986b69625a0f3443e9091754874b3f9d65e9913424298c5d8fedf733985",
|
||||
"candidate": {
|
||||
"description": "collect static current rolling windows at strategy construction, batch them per stock, and store fixed current close/volume arrays in StockExpressionState",
|
||||
"processColdEngineSeconds": 6.412,
|
||||
"processHotEngineSeconds": [6.046, 6.497, 6.035, 6.309, 6.074],
|
||||
"processHotMedianEngineSeconds": 6.074
|
||||
},
|
||||
"rollback": {
|
||||
"processColdEngineSeconds": 5.18,
|
||||
"processHotEngineSeconds": [5.48, 5.47, 4.602],
|
||||
"processHotMedianEngineSeconds": 5.47
|
||||
},
|
||||
"observedCandidateRegressionPercent": 11.04204753199269,
|
||||
"businessContract": {
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 421,
|
||||
"corePassed": 415,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/batched-current-rolling-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/batched-current-rolling-rollback-20260905.json"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "the larger per-stock state and eager batch work cost more than the repeated scalar helper calls on the real five-year workload"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"schemaVersion": "fidc-cached-symbol-board-rejection/v1",
|
||||
"measuredAt": "2026-09-05T03:22:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "eb8b146",
|
||||
"revertCommit": "f210539",
|
||||
"candidate": {
|
||||
"description": "precompute a symbol-id-aligned BJSE bit vector and share one suffix classifier between platform expressions and risk control",
|
||||
"primaryHotEngineSeconds": [4.656, 4.759, 4.669, 4.698, 4.614],
|
||||
"primaryHotMedianEngineSeconds": 4.669,
|
||||
"acceptedPrimaryBaselineMedianEngineSeconds": 4.729,
|
||||
"primaryObservedImprovementPercent": 1.2687661249735674,
|
||||
"secondaryLowContentionEngineSeconds": [4.404, 4.327, 4.346],
|
||||
"secondaryLowContentionMedianEngineSeconds": 4.346,
|
||||
"acceptedSecondaryBaselineMedianEngineSeconds": 4.049,
|
||||
"secondaryObservedRegressionPercent": 7.33514448011855,
|
||||
"highContentionSecondaryEngineSecondsExcluded": [6.398]
|
||||
},
|
||||
"rollbackComparison": {
|
||||
"primaryEngineSecondsExcluded": [12.736, 12.795, 12.899, 13.845],
|
||||
"hostLoadAverage": 44.84,
|
||||
"reason": "managed symbolic workers entered a roughly 30-core phase, so the rollback batch cannot serve as a same-load wall-time comparison"
|
||||
},
|
||||
"businessContract": {
|
||||
"primaryTotalReturn": 0.9219861819172002,
|
||||
"primaryTradeCount": 26088,
|
||||
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"primaryResultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"secondaryTotalReturn": 1.1342962298106998,
|
||||
"secondaryTradeCount": 19404,
|
||||
"secondaryCanonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"secondaryResultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"profile": {
|
||||
"allThreadRunId": "btr_1788549525151_3404548_13",
|
||||
"allThreadRunEngineSeconds": 4.957,
|
||||
"trimMatchesPercent": 2.0,
|
||||
"typedBaselineSingleWorkerProfileTrimMatchesPercent": 2.85,
|
||||
"comparisonLimited": true,
|
||||
"reason": "the two profiles used different thread attachment sets and cannot establish an end-to-end speedup"
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-rollback-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/cached-symbol-board-all-threads-profile-20260905/perf.data"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "the candidate preserved correctness but did not improve both five-year strategies, and the later rollback batch was too heavily contended to overturn the cross-strategy regression"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schemaVersion": "fidc-calendar-major-series-boundary-index/v1",
|
||||
"measuredAt": "2026-09-05T02:24:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "abe4fed4527e07ad7ae4464e574fa582150e306e",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "a4135986b69625a0f3443e9091754874b3f9d65e9913424298c5d8fedf733985",
|
||||
"implementation": {
|
||||
"description": "transpose immutable decision/current market-series boundary indexes from symbol-major vectors to calendar-major contiguous symbol rows",
|
||||
"logicalEntryCountChanged": false,
|
||||
"entryType": "u32",
|
||||
"missingSentinel": "u32::MAX",
|
||||
"factorValuesCached": false,
|
||||
"selectionResultsCached": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"processCold": {
|
||||
"totalSeconds": 18.605,
|
||||
"dataSeconds": 12.849,
|
||||
"dataSetConstructSeconds": 4.052,
|
||||
"marketIndexBuildSeconds": 2.396,
|
||||
"engineSeconds": 5.06
|
||||
},
|
||||
"processHotEngineSeconds": [5.297, 5.227, 5.029, 5.012, 5.202],
|
||||
"processHotMedianEngineSeconds": 5.202,
|
||||
"fieldProjectionBaselineMedianEngineSeconds": 5.356,
|
||||
"observedMedianImprovementPercent": 2.875280059746078,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [4.646, 4.87, 4.886, 5.056],
|
||||
"medianEngineSeconds": 4.878,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788546155152_3120069_10",
|
||||
"engineSeconds": 5.211,
|
||||
"eventCycles": 14556215580,
|
||||
"seriesEndIndexPresentInTopProfile": false,
|
||||
"fieldProjectionBaselineSeriesEndIndexPercent": 4.74,
|
||||
"stockStateSelfPercent": 15.19,
|
||||
"adjustedCloseMovingAveragePercent": 6.24,
|
||||
"numericVmPercent": 6.77
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11493711872,
|
||||
"serviceCgroupPeakBytes": 11495387136,
|
||||
"processRssKiB": 11212504,
|
||||
"processAnonymousKiB": 11196148,
|
||||
"fieldProjectionBaselineCgroupCurrentBytes": 11485425664,
|
||||
"observedCgroupIncreaseBytes": 8286208
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 421,
|
||||
"corePassed": 415,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-major-series-boundary-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_generic_calendar_major_boundary_index",
|
||||
"reason": "both five-year contracts preserve exact outputs and clean terminal audits, the primary hot median improves, the former boundary lookup leaves the top profile, and steady-state memory remains effectively unchanged"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schemaVersion": "fidc-compact-adjusted-close-rejection/v1",
|
||||
"measuredAt": "2026-09-05T04:29:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "ab87e18",
|
||||
"revertCommit": "0c2681e6996800eae5f3b881e75a01e7a078863f",
|
||||
"restoredRunnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
|
||||
"candidate": {
|
||||
"description": "replace two Vec<Option<f64>> adjusted-close arrays with f64 arrays using NaN as the internal missing sentinel",
|
||||
"theoreticalSteadyStateMemoryReductionBytesPerMarketRow": 16,
|
||||
"hotEngineSeconds": [5.119, 5.168, 5.516, 5.332, 4.69],
|
||||
"hotMedianEngineSeconds": 5.168
|
||||
},
|
||||
"acceptedBaseline": {
|
||||
"historicalMedianEngineSeconds": 3.896,
|
||||
"sameWindowRollbackEngineSeconds": [4.144, 4.169],
|
||||
"sameWindowRollbackMedianEngineSeconds": 4.169
|
||||
},
|
||||
"observed": {
|
||||
"regressionVersusHistoricalBaselinePercent": 32.64887063655031,
|
||||
"regressionVersusSameWindowRollbackPercent": 23.962580954665402
|
||||
},
|
||||
"businessContract": {
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 422,
|
||||
"corePassed": 416,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/compact-adjusted-close-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/compact-adjusted-close-rollback-primary-20260905.json"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "the memory-dense NaN representation materially slowed the hottest moving-average path despite preserving exact business results"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"schemaVersion": "fidc-compact-daily-stock-state-cache-key/v1",
|
||||
"measuredAt": "2026-09-05T01:20:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "6f81e1940ad4ddd4eefbe12c056be28f2b64d127",
|
||||
"runnerBinarySha256": "c2d9101a89b71b51051b8762375476518e40d40e29a8ee0f9615ac4c55fcdb0a",
|
||||
"implementation": {
|
||||
"description": "remove the redundant execution date from stock-state cache keys because the complete cache is already cleared before the active date changes",
|
||||
"keyBefore": "(execution_date, factor_date, symbol_id, execution_time, use_intraday_quote)",
|
||||
"keyAfter": "(factor_date, symbol_id, execution_time, use_intraday_quote)",
|
||||
"crossDateResetTested": true,
|
||||
"cachedFactorValues": false,
|
||||
"cachedSelectionResults": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"hotEngineSeconds": [5.262, 5.734, 5.89, 5.612, 6.301],
|
||||
"hotMedianEngineSeconds": 5.734,
|
||||
"sharedOrderBaselineMedianEngineSeconds": 5.742,
|
||||
"observedMedianImprovementPercent": 0.13932427725531174,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [5.889, 5.853, 5.342],
|
||||
"medianEngineSeconds": 5.853,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"coldDataObservation": {
|
||||
"runId": "btr_1788542108713_2936456_0",
|
||||
"totalSeconds": 54.502,
|
||||
"dataSeconds": 46.584,
|
||||
"engineSeconds": 7.011,
|
||||
"sourceQuerySeconds": 17.585,
|
||||
"datasetConstructSeconds": 12.913,
|
||||
"marketIndexBuildSeconds": 7.671,
|
||||
"adjustmentValidationSeconds": 1.855,
|
||||
"hotDataSecondsRange": [0.002, 0.006],
|
||||
"performanceComparisonExcluded": true
|
||||
},
|
||||
"memoryObservation": {
|
||||
"processRssKiB": 11136672,
|
||||
"processAnonymousKiB": 11120428,
|
||||
"cgroupCurrentBytes": 12445237248,
|
||||
"cgroupFileCacheBytes": 1029632000,
|
||||
"cgroupActiveFileBytes": 1009012736,
|
||||
"duplicateDataSetLeakObserved": false,
|
||||
"note": "the gap between process RSS and cgroup memory is reclaimable file-page cache rather than a second resident DataSet"
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 420,
|
||||
"corePassed": 414,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/compact-stock-cache-key-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/compact-stock-cache-key-secondary-20260905.json"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_structural_compaction_no_regression",
|
||||
"endToEndSpeedupClaimed": false,
|
||||
"reason": "the key is smaller, the cross-date isolation test passes, both five-year strategies retain exact results, and observed wall time is neutral to slightly better"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"schemaVersion": "fidc-current-rolling-boundary-reuse-ab/v1",
|
||||
"measuredAt": "2026-09-04T15:17:49Z",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "52b07be19bdd1597e5b412b5a549e57640efa415",
|
||||
"implementation": {
|
||||
"description": "resolve the current market-series end once per stock state and reuse the integer boundary for literal stock rolling_mean_current calls",
|
||||
"cachedFactorValues": false,
|
||||
"cachedStrategyResults": false,
|
||||
"additionalHeapCollectionsPerStock": 0,
|
||||
"fallback": "dynamic or non-market fields retain the original date and factor-series lookup",
|
||||
"pitSemanticsChanged": false,
|
||||
"adjustmentSemanticsChanged": false
|
||||
},
|
||||
"componentBenchmark": {
|
||||
"iterations": 100000,
|
||||
"helperCallsPerIteration": 5,
|
||||
"repeatedLookupSeconds": 0.009142143,
|
||||
"reusedBoundarySeconds": 0.006728638,
|
||||
"speedup": 1.3586914617787433,
|
||||
"equal": true
|
||||
},
|
||||
"testGate": {
|
||||
"total": 418,
|
||||
"passed": 413,
|
||||
"ignoredManualBenchmarks": 5,
|
||||
"failed": 0
|
||||
},
|
||||
"backtestEvidence": {
|
||||
"runId": "btr_1788535029326_2441112_5",
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalSeconds": 7.509,
|
||||
"engineSeconds": 6.494,
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54"
|
||||
},
|
||||
"secondaryStrategyEvidence": {
|
||||
"description": "same frozen bundle and execution contract with target positions changed from 30 to 20",
|
||||
"runId": "btr_1788535290995_2441112_6",
|
||||
"totalSeconds": 9.081,
|
||||
"engineSeconds": 8.367,
|
||||
"dataSeconds": 0.003,
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"tradeDateCount": 1216,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"matchesPreOptimizationBusinessResult": true
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "/srv/fidc/canonical/run/fidc-private/evidence/current-rolling-boundary-profile-20260904/perf.data",
|
||||
"sha256": "be9f6e04c01a98cb834bc3f207359dc17555e0e660e178d552b525722a13873a"
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/run/fidc-private/evidence/current-rolling-boundary-profile-20260904/perf-report.txt",
|
||||
"sha256": "6f4e1e4370ce996fcf7308a7a723469b417d8c03156eb43b5a26a5fb42c42511"
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/run/fidc-private/evidence/current-rolling-boundary-profile-20260904/run.json",
|
||||
"sha256": "fee9f104d72d8918cdcd7660d7cd40dced9a84edcabcd68e89588ce5690523a5"
|
||||
}
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_component_improvement_end_to_end_no_regression",
|
||||
"endToEndSpeedupClaimed": false,
|
||||
"reason": "the component result is positive and exact, while concurrent non-FIDC tan load and symbolic phases make cross-build wall-time medians non-comparable"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schemaVersion": "fidc-daily-snapshot-view-stock-state-rejection/v1",
|
||||
"measuredAt": "2026-09-05T00:30:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "1df0081479b93a051f9a40d2acf7060d0eb6929d",
|
||||
"revertCommit": "1f10a6bb3decdd40c77397a7596c6f23a9f90a18",
|
||||
"candidate": {
|
||||
"description": "carry symbol_id in EligibleUniverseSnapshot and pass pre-resolved execution/factor DailySnapshotView values into stock-state construction",
|
||||
"componentBenchmark": {
|
||||
"symbols": 6000,
|
||||
"rounds": 200,
|
||||
"dateMapLookupSeconds": 0.020211,
|
||||
"dailyViewLookupSeconds": 0.006410,
|
||||
"speedup": 3.1530421216848675,
|
||||
"equal": true
|
||||
},
|
||||
"fiveYearRuns": [
|
||||
{"runId": "btr_1788538826285_2693431_0", "engineSeconds": 8.075},
|
||||
{"runId": "btr_1788538854544_2693431_1", "engineSeconds": 8.488},
|
||||
{"runId": "btr_1788538868210_2693431_2", "engineSeconds": 10.455},
|
||||
{"runId": "btr_1788538887159_2693431_3", "engineSeconds": 10.672},
|
||||
{"runId": "btr_1788538903432_2693431_4", "engineSeconds": 11.284},
|
||||
{"runId": "btr_1788538920072_2693431_5", "engineSeconds": 9.985}
|
||||
],
|
||||
"hotMedianEngineSeconds": 10.455,
|
||||
"runnerBinarySha256": "cbe20d1bd5c5e02372bd310107a1f4e41aaa9f256b3c31f79c6ad000648d1854"
|
||||
},
|
||||
"restored": {
|
||||
"fiveYearRuns": [
|
||||
{"runId": "btr_1788539286281_2739751_0", "engineSeconds": 7.118},
|
||||
{"runId": "btr_1788539313845_2739751_1", "engineSeconds": 6.702},
|
||||
{"runId": "btr_1788539325631_2739751_2", "engineSeconds": 7.093},
|
||||
{"runId": "btr_1788539338757_2739751_3", "engineSeconds": 6.509}
|
||||
],
|
||||
"hotMedianEngineSeconds": 6.702,
|
||||
"candidateRegressionPercent": 55.99820948970456,
|
||||
"runnerBinarySha256": "e33dd75aa7b481975c0991cf634d07900cdefa51756802e28e64f18b980a83dc",
|
||||
"matchesPreCandidateRunnerSha256": true
|
||||
},
|
||||
"businessContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"allRunsConsistent": true
|
||||
},
|
||||
"profile": {
|
||||
"candidateRunId": "btr_1788538966659_2693431_6",
|
||||
"candidateEngineSeconds": 10.566,
|
||||
"stockStatePercent": 28.14,
|
||||
"adjustedCloseMovingAveragePercent": 8.27,
|
||||
"stableSortPercent": 5.46,
|
||||
"mallocPercent": 5.12,
|
||||
"note": "The removed map lookup symbols disappeared, but their component savings were too small to offset the larger universe row and generalized optional-view state path."
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-after-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-profile-20260905/perf-report.txt",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/daily-view-reverted-20260905.json"
|
||||
],
|
||||
"decision": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "a positive lookup microbenchmark did not translate to end-to-end performance; the same frozen five-year workload became about 56 percent slower",
|
||||
"productionEngineCommit": "1f10a6bb3decdd40c77397a7596c6f23a9f90a18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"schemaVersion": "fidc.date-numeric-vm-performance/v1",
|
||||
"generatedAt": "2026-09-06T18:08:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"change": {
|
||||
"engineCommit": "bc228980afc02cde483b1a6fe69da4048120cbc4",
|
||||
"serviceCommit": "bac07c208c652a717d312e332d44f90218cc1247",
|
||||
"runnerBinarySha256": "46684dea71273466f8ba0f6351eb6f6adf694393ff23ee278054e3b6b71acbe5",
|
||||
"description": "Valid ISO date literals directly compared with trade_date, current_date, date, decision_date or execution_date compile to the numeric bytecode VM as YYYYMMDD keys. All other strings remain on the Rhai path. Numeric VM floating comparisons now match Rhai relative epsilon semantics."
|
||||
},
|
||||
"correctnessBug": {
|
||||
"previousBehavior": "Numeric VM used exact f64 comparisons while Rhai used relative f64::EPSILON comparisons, so an expression could change result depending on whether it compiled to VM or fell back to Rhai.",
|
||||
"capturedExample": "11.699999999999998 == 11.7",
|
||||
"previousVmResult": false,
|
||||
"rhaiAndFixedVmResult": true,
|
||||
"operatorsAligned": [
|
||||
"==",
|
||||
"!=",
|
||||
"<",
|
||||
"<=",
|
||||
">",
|
||||
">="
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"sourceRunId": "btr_1788681250851_3170500_1",
|
||||
"startDate": "2023-01-03",
|
||||
"endDate": "2025-12-31",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"initialCash": 10000000.0,
|
||||
"benchmark": "000852.SH",
|
||||
"frozenBundleReused": true
|
||||
},
|
||||
"baseline": {
|
||||
"engineCommit": "840473362d7032b80d551ba5935723ee823dffa1",
|
||||
"hotEngineMedianSeconds": 5.049,
|
||||
"hotTotalMedianSeconds": 5.796
|
||||
},
|
||||
"candidate": {
|
||||
"coldRun": {
|
||||
"runId": "btr_1788688964051_3295756_0",
|
||||
"totalSeconds": 21.341,
|
||||
"dataSeconds": 7.904,
|
||||
"engineSeconds": 12.519,
|
||||
"classification": "post-deploy process cold and concurrent host-load observation; retained explicitly and excluded from the hot median"
|
||||
},
|
||||
"hotRunIds": [
|
||||
"btr_1788688990229_3295756_1",
|
||||
"btr_1788689001276_3295756_2",
|
||||
"btr_1788689030863_3295756_3",
|
||||
"btr_1788689059131_3295756_4"
|
||||
],
|
||||
"hotEngineSeconds": [
|
||||
4.963,
|
||||
4.934,
|
||||
4.854,
|
||||
4.859
|
||||
],
|
||||
"hotEngineMedianSeconds": 4.8965,
|
||||
"hotTotalSeconds": [
|
||||
5.901,
|
||||
5.619,
|
||||
5.738,
|
||||
5.539
|
||||
],
|
||||
"hotTotalMedianSeconds": 5.6785,
|
||||
"incrementalHotEngineImprovementPercent": 3.0204,
|
||||
"incrementalHotTotalImprovementPercent": 2.0273
|
||||
},
|
||||
"resultParity": {
|
||||
"totalReturn": -0.615167693429,
|
||||
"tradeCount": 4961,
|
||||
"canonicalResultSha256": "f9c26271d4f2096e51429b6106740f600757823789bf6447cd0c524f702f0836",
|
||||
"resultStoreSha256": "56042bd25bd4261e41e8237c9f5ead580b782d897baae58c8e4436f331a1d91f",
|
||||
"allFiveRunsEqualBaseline": true,
|
||||
"returnDelta": 0.0,
|
||||
"tradeCountDelta": 0
|
||||
},
|
||||
"validation": {
|
||||
"numericVmTests": "4 passed, 1 ignored",
|
||||
"capturedRegressionTest": "platform_helpers_support_generic_rolling_stats_and_normalized_factors",
|
||||
"workspaceTests": "423 passed, 6 ignored plus all integration tests passed",
|
||||
"invalidDateLiteralFallsBack": true,
|
||||
"nonDateStringFallsBack": true,
|
||||
"helperDateArgumentPreserved": true,
|
||||
"runtimeUser": "boris",
|
||||
"serviceHealth": "ok"
|
||||
}
|
||||
}
|
||||
@@ -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,78 @@
|
||||
{
|
||||
"schemaVersion": "fidc-duplicate-rolling-lookback-rejection/v1",
|
||||
"measuredAt": "2026-09-05T01:05:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommits": [
|
||||
"1f02e78b2400c73c5f05351b0beff16d6c724e2a",
|
||||
"e469b0ddf4aa3bb9fe69b785b51ce6c7cf960b90"
|
||||
],
|
||||
"revertCommit": "5b2a03d416a7bd709680a1adf52013e3eadc199a",
|
||||
"candidate": {
|
||||
"description": "reuse equal lookback values only inside one AdjustedCloseSeries moving_averages_at_end call",
|
||||
"validComponentBenchmark": {
|
||||
"samples": 6,
|
||||
"iterationsPerSample": 3000000,
|
||||
"lookbacks": [1, 2, 3, 1, 2, 3, 1],
|
||||
"repeatedSeconds": 1.40643611,
|
||||
"reusedSeconds": 0.853281932,
|
||||
"speedup": 1.6482666012902287,
|
||||
"checksum": 1458000000.0
|
||||
},
|
||||
"hardwareCounters": {
|
||||
"runId": "btr_1788541184367_2862338_11",
|
||||
"engineSeconds": 8.423,
|
||||
"taskClockMilliseconds": 9030.56,
|
||||
"cycles": 22931608591,
|
||||
"instructions": 28199702874,
|
||||
"branches": 5010114942,
|
||||
"branchMisses": 23586244,
|
||||
"cacheReferences": 268428800,
|
||||
"cacheMisses": 146632717
|
||||
}
|
||||
},
|
||||
"restoredBaseline": {
|
||||
"warmupRunId": "btr_1788541416661_2890172_0",
|
||||
"warmupEngineSeconds": 5.581,
|
||||
"hardwareCounters": {
|
||||
"runId": "btr_1788541455567_2890172_1",
|
||||
"engineSeconds": 6.078,
|
||||
"taskClockMilliseconds": 6643.43,
|
||||
"cycles": 16773013609,
|
||||
"instructions": 28218847364,
|
||||
"branches": 5024236708,
|
||||
"branchMisses": 23209586,
|
||||
"cacheReferences": 266316407,
|
||||
"cacheMisses": 81226408
|
||||
},
|
||||
"runnerBinarySha256": "188269bf0c2ee65d6c11ed37ddddfdaf86695941e8123f69f950054bea39796e"
|
||||
},
|
||||
"comparison": {
|
||||
"candidateInstructionReductionPercent": 0.06784229696471904,
|
||||
"candidateBranchReductionPercent": 0.2810720756590066,
|
||||
"materialSystemImprovementProven": false,
|
||||
"wallTimeComparable": false,
|
||||
"reason": "host-wide thermal and external workload changes produced materially different cache-miss and clock conditions; the instruction reduction itself is below one tenth of one percent"
|
||||
},
|
||||
"businessContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"allRunsConsistent": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-low-contention-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-perf-stat-candidate-20260905/perf-stat.csv",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/duplicate-lookback-perf-stat-baseline-20260905/perf-stat.csv"
|
||||
],
|
||||
"decision": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "the valid microbenchmark improves, but the real five-year workload saves only about 0.068 percent instructions and provides no measurable system-level benefit",
|
||||
"productionEngineCommit": "5b2a03d416a7bd709680a1adf52013e3eadc199a"
|
||||
}
|
||||
}
|
||||
@@ -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,108 @@
|
||||
{
|
||||
"schemaVersion": "fidc.engine-expression-scope-performance/v1",
|
||||
"generatedAt": "2026-09-06T17:45:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"profile": {
|
||||
"artifact": "/srv/fidc/canonical/run/fidc-private/evidence/profiles/fidc-hot-engine-d324d284-20260906.data",
|
||||
"event": "cpu-clock",
|
||||
"frequencyHz": 499,
|
||||
"samples": 3501,
|
||||
"lostSamples": 0,
|
||||
"scope": "system.slice/fidc-backtest-service-highmem177.service",
|
||||
"observedSelfOverheadPercent": {
|
||||
"chronoDelayedFormat": 6.31,
|
||||
"chronoStrftimeIterator": 5.0,
|
||||
"selectiveScopePushDynamic": 5.2,
|
||||
"selectiveScopePushString": 3.48,
|
||||
"selectiveScopePushInteger": 3.03,
|
||||
"selectiveScopePushBoolean": 1.09,
|
||||
"marketBySymbolId": 6.2,
|
||||
"malloc": 4.74,
|
||||
"free": 2.43
|
||||
}
|
||||
},
|
||||
"change": {
|
||||
"baselineEngineCommit": "630a7a1",
|
||||
"candidateEngineCommit": "840473362d7032b80d551ba5935723ee823dffa1",
|
||||
"serviceCommit": "bac07c208c652a717d312e332d44f90218cc1247",
|
||||
"behavior": "Expression scope identifiers are passed directly to Rhai without an intermediate heap String. Trade, decision and execution date strings are formatted once per DayExpressionState and reused as ImmutableString values by every expression evaluation on that day."
|
||||
},
|
||||
"request": {
|
||||
"sourceRunId": "btr_1788681250851_3170500_1",
|
||||
"startDate": "2023-01-03",
|
||||
"endDate": "2025-12-31",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"initialCash": 10000000.0,
|
||||
"benchmark": "000852.SH",
|
||||
"frozenBundleReused": true
|
||||
},
|
||||
"baseline": {
|
||||
"runIds": [
|
||||
"btr_1788686878533_3170500_3",
|
||||
"btr_1788686907702_3170500_4",
|
||||
"btr_1788687153269_3170500_5"
|
||||
],
|
||||
"engineSeconds": [
|
||||
6.582,
|
||||
6.442,
|
||||
6.352
|
||||
],
|
||||
"engineMedianSeconds": 6.442,
|
||||
"totalSeconds": [
|
||||
7.22,
|
||||
7.04,
|
||||
7.388
|
||||
],
|
||||
"totalMedianSeconds": 7.22
|
||||
},
|
||||
"candidate": {
|
||||
"coldRun": {
|
||||
"runId": "btr_1788687746484_3277755_0",
|
||||
"totalSeconds": 13.369,
|
||||
"dataSeconds": 7.827,
|
||||
"engineSeconds": 4.925,
|
||||
"classification": "runner_process_cold"
|
||||
},
|
||||
"hotRunIds": [
|
||||
"btr_1788687763649_3277755_1",
|
||||
"btr_1788687774308_3277755_2",
|
||||
"btr_1788687798961_3277755_3",
|
||||
"btr_1788687813292_3277755_4"
|
||||
],
|
||||
"hotEngineSeconds": [
|
||||
5.021,
|
||||
5.077,
|
||||
5.18,
|
||||
4.951
|
||||
],
|
||||
"hotEngineMedianSeconds": 5.049,
|
||||
"hotTotalSeconds": [
|
||||
5.659,
|
||||
5.756,
|
||||
5.836,
|
||||
5.84
|
||||
],
|
||||
"hotTotalMedianSeconds": 5.796,
|
||||
"engineMedianImprovementPercent": 21.624,
|
||||
"totalMedianImprovementPercent": 19.723
|
||||
},
|
||||
"correctness": {
|
||||
"totalReturn": -0.615167693429,
|
||||
"tradeCount": 4961,
|
||||
"canonicalResultSha256": "f9c26271d4f2096e51429b6106740f600757823789bf6447cd0c524f702f0836",
|
||||
"resultStoreSha256": "56042bd25bd4261e41e8237c9f5ead580b782d897baae58c8e4436f331a1d91f",
|
||||
"allFiveCandidateRunsEqualBaseline": true,
|
||||
"returnDelta": 0.0,
|
||||
"tradeCountDelta": 0
|
||||
},
|
||||
"validation": {
|
||||
"cargoFmt": "passed",
|
||||
"platformExpressionTests": "180 passed, 1 ignored",
|
||||
"workspaceTests": "421 passed, 6 ignored plus all integration tests passed",
|
||||
"runtimeUser": "boris",
|
||||
"serviceHealth": "ok",
|
||||
"buildManifest": "/srv/fidc/canonical/run/build/fidc-backtest-service.json",
|
||||
"runnerBinarySha256": "909cb47ef20128b4990bf355d0c63e10d7d74aac128067fa19c971698c5d85bc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schemaVersion": "fidc-frozen-standard-rolling-lookbacks-rejection/v1",
|
||||
"measuredAt": "2026-09-05T03:03:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "32e4030",
|
||||
"revertCommit": "05e67c73df0c002efec15711dd5ef066d817abec",
|
||||
"restoredRunnerBinarySha256": "1071852a374620029398a967d485b827ed2defbb6ecbcc3f1ee6ffd3e2db76b6",
|
||||
"candidate": {
|
||||
"description": "replace per-stock rolling requirement-set lookups with strategy-level fixed standard lookback arrays",
|
||||
"primaryHotEngineSeconds": [4.329, 4.455, 4.751, 4.822, 4.847],
|
||||
"primaryHotMedianEngineSeconds": 4.751,
|
||||
"secondaryEngineSeconds": [4.774, 5.473, 6.161, 5.491],
|
||||
"hardwareRunId": "btr_1788548442183_3221468_10",
|
||||
"hardwareRunEngineSeconds": 10.522,
|
||||
"hardwareRunHostLoadAverage": 42.93,
|
||||
"instructions": 26384933876,
|
||||
"branches": 4682344116
|
||||
},
|
||||
"acceptedBaseline": {
|
||||
"engineCommit": "75ab0c0",
|
||||
"primaryHotMedianEngineSeconds": 4.729,
|
||||
"instructions": 26049740736,
|
||||
"branches": 4601262679
|
||||
},
|
||||
"observed": {
|
||||
"primaryMedianRegressionPercent": 0.4652146331148285,
|
||||
"instructionRegressionPercent": 1.2867427104054538,
|
||||
"branchRegressionPercent": 1.7621562309418415
|
||||
},
|
||||
"businessContract": {
|
||||
"primaryTotalReturn": 0.9219861819172002,
|
||||
"primaryTradeCount": 26088,
|
||||
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"primaryResultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"secondaryTotalReturn": 1.1342962298106998,
|
||||
"secondaryTradeCount": 19404,
|
||||
"secondaryCanonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"secondaryResultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/frozen-standard-rolling-lookbacks-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/frozen-standard-rolling-lookbacks-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/frozen-standard-rolling-lookbacks-perf-stat-20260905/perf-stat.csv"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "the compiler layout change increased retired instructions and branches while the primary end-to-end median did not improve"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"schemaVersion": "fidc-instrument-symbol-id-index-ab/v1",
|
||||
"measuredAt": "2026-09-04T14:31:25Z",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "8ed22897eac506297c9ab4023140fbb6cfa0cc26",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"serviceBinarySha256": "24b53421e8724a29963e3254d9dea33c0a2259a81567caa853b98369df7cf704",
|
||||
"runnerBinarySha256": "0d314809675544fed19ba0888ae22e0098f87da1672ac692fe38f3dbd3b55c70",
|
||||
"implementation": {
|
||||
"description": "build one immutable Instrument vector aligned to the existing symbol-id index and use it only at call sites that already own a validated symbol id",
|
||||
"stringInstrumentApiPreserved": true,
|
||||
"mutableStateShared": false,
|
||||
"riskSemanticsChanged": false,
|
||||
"executionSemanticsChanged": false
|
||||
},
|
||||
"componentBenchmark": {
|
||||
"rows": 6000,
|
||||
"iterations": 1000,
|
||||
"hashMapSeconds": 0.09067713,
|
||||
"denseVectorSeconds": 0.010526148,
|
||||
"speedup": 8.614464664566752,
|
||||
"checksum": 600000000,
|
||||
"equal": true
|
||||
},
|
||||
"testGate": {
|
||||
"total": 417,
|
||||
"passed": 413,
|
||||
"ignoredManualBenchmarks": 4,
|
||||
"failed": 0
|
||||
},
|
||||
"backtestContract": {
|
||||
"bundleId": "bt_bundle_5baf27b98346123e1a7b",
|
||||
"bundleHash": "68571cc2c3082cacf1774e681d6ff82d1beddb4241cebc2179f9235001cd730f",
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"tradeDateCount": 1216,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54"
|
||||
},
|
||||
"before": {
|
||||
"runtime": "5dec29a0d6ee",
|
||||
"lowOverheadEngineSeconds": [7.363, 6.922, 6.917],
|
||||
"medianEngineSeconds": 6.922
|
||||
},
|
||||
"after": {
|
||||
"processColdRun": {
|
||||
"runId": "btr_1788533379797_2361496_0",
|
||||
"totalSeconds": 20.534,
|
||||
"dataSeconds": 13.02,
|
||||
"engineSeconds": 6.377
|
||||
},
|
||||
"hotRuns": [
|
||||
{"runId": "btr_1788533414497_2361496_1", "totalSeconds": 7.182, "engineSeconds": 6.517},
|
||||
{"runId": "btr_1788533424201_2361496_2", "totalSeconds": 7.164, "engineSeconds": 6.482},
|
||||
{"runId": "btr_1788533436894_2361496_3", "totalSeconds": 7.289, "engineSeconds": 6.48},
|
||||
{"runId": "btr_1788533447093_2361496_4", "totalSeconds": 7.367, "engineSeconds": 6.477}
|
||||
],
|
||||
"medianHotEngineSeconds": 6.481,
|
||||
"medianEngineImprovementPercent": 6.37,
|
||||
"lowOverheadProfileRun": {
|
||||
"runId": "btr_1788533478319_2361496_5",
|
||||
"totalSeconds": 6.917,
|
||||
"engineSeconds": 6.251,
|
||||
"sampleCount": 332,
|
||||
"lostSamples": 0
|
||||
},
|
||||
"serviceMemoryCurrentBytes": 11426889728,
|
||||
"serviceMemoryPeakBytes": 11434971136
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "/srv/fidc/canonical/run/fidc-private/evidence/instrument-symbol-id-profile-20260904/hot-five-year-perf.data",
|
||||
"sha256": "a3d788f8e6ad799604b83019a4403fee509d6c955606f46bd721960e3d38433b"
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/run/fidc-private/evidence/instrument-symbol-id-profile-20260904/hot-five-year-perf-report.txt",
|
||||
"sha256": "dbd3004b6ab2719e67b34ca4c6d98f908053f6f28f617bd1c1969552b0f14291"
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/run/fidc-private/evidence/instrument-symbol-id-profile-20260904/hot-five-year-run.json",
|
||||
"sha256": "092276999525532005a815ab9d2588ee8865b4652c11367ca9ffe52b6a601557"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schemaVersion": "fidc-interned-stock-state-symbols/v1",
|
||||
"measuredAt": "2026-09-05T01:50:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "e5646ef80c9a5c6d2d3a1b9b6f330fbbc27aa4ab",
|
||||
"runnerBinarySha256": "c2f6f0b8cc194a5e3f668bcc0d54b3712cf037267ccc6c04d5f850d8ccbd6314",
|
||||
"implementation": {
|
||||
"description": "intern each canonical symbol once in DataSet and clone Arc<str> into per-date StockExpressionState objects instead of allocating a String for every state",
|
||||
"rhaiStringBoundaryPreserved": true,
|
||||
"crossDateSharedSymbolTested": true,
|
||||
"cachedFactorValues": false,
|
||||
"cachedSelectionResults": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"lowContentionEngineSeconds": [5.505, 5.748, 5.67, 6.005, 6.497],
|
||||
"medianEngineSeconds": 5.748,
|
||||
"compactKeyBaselineMedianEngineSeconds": 5.734,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [5.648, 5.35, 5.003],
|
||||
"medianEngineSeconds": 5.35,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"hardwareCounters": {
|
||||
"candidateRunId": "btr_1788544043073_3041540_6",
|
||||
"candidateEngineSeconds": 8.227,
|
||||
"candidateInstructions": 27997441984,
|
||||
"candidateBranches": 4947191270,
|
||||
"earlierSharedIndexBaselineInstructions": 28218847364,
|
||||
"earlierSharedIndexBaselineBranches": 5024236708,
|
||||
"observedInstructionReductionPercent": 0.7846044523842924,
|
||||
"observedBranchReductionPercent": 1.533451728719079,
|
||||
"attributionNote": "the earlier hardware baseline predates the compact cache-key change, so counters prove the combined structural reduction rather than symbol interning alone"
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788544253262_3041540_15",
|
||||
"totalSeconds": 6.136,
|
||||
"engineSeconds": 5.509,
|
||||
"eventCycles": 15296376746,
|
||||
"stockStatePercent": 16.25,
|
||||
"preInternStockStatePercent": 23.86,
|
||||
"mallocPercent": 4.22,
|
||||
"numericVmPercent": 6.02,
|
||||
"adjustedCloseMovingAveragePercent": 5.10
|
||||
},
|
||||
"excludedHighContentionBatch": {
|
||||
"hostLoadAveragePeak": 69.23,
|
||||
"engineSeconds": [7.247, 7.094, 7.481, 7.967, 37.981, 44.366],
|
||||
"resultConsistent": true,
|
||||
"performanceComparisonExcluded": true,
|
||||
"reason": "independent tan child processes executed on backtest-reserved CPUs; no external process was modified"
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11485425664,
|
||||
"serviceCgroupPeakBytes": 11488174080,
|
||||
"processRssKiB": 11196756,
|
||||
"processAnonymousKiB": 11180328
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 420,
|
||||
"corePassed": 414,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-low-contention-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-perf-stat-20260905/perf-stat.csv",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/interned-stock-symbol-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_structural_allocation_reduction_no_regression",
|
||||
"endToEndSpeedupClaimed": false,
|
||||
"reason": "both five-year strategies preserve exact outputs, low-contention wall time is neutral, and the stock-state profile plus hardware counters show less allocation-related work"
|
||||
}
|
||||
}
|
||||
@@ -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,56 @@
|
||||
{
|
||||
"schemaVersion": "fidc-lazy-expression-scope-rejection/v1",
|
||||
"measuredAt": "2026-09-05T04:06:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "1b44928",
|
||||
"revertCommit": "229ca8332df050c6961f73bd1a17de0dab583cf0",
|
||||
"restoredRunnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
|
||||
"candidate": {
|
||||
"description": "add closure-based lazy SelectiveExpressionScope bindings for dates, order/process state, stock strings, and position availability",
|
||||
"primaryHotEngineSeconds": [3.838, 3.867, 4.017, 3.931, 3.866],
|
||||
"primaryHotMedianEngineSeconds": 3.867,
|
||||
"secondaryEngineSeconds": [3.664, 3.649, 3.693, 3.767],
|
||||
"secondaryMedianEngineSeconds": 3.693
|
||||
},
|
||||
"acceptedBaseline": {
|
||||
"primaryMedianEngineSeconds": 3.896,
|
||||
"secondaryMedianEngineSeconds": 3.582
|
||||
},
|
||||
"observed": {
|
||||
"primaryImprovementPercent": 0.744353182751538,
|
||||
"secondaryRegressionPercent": 3.098827470686773
|
||||
},
|
||||
"rollbackBatchExcluded": {
|
||||
"secondaryEngineSeconds": [5.343, 5.352, 6.996],
|
||||
"hostLoadAverage": 28.38,
|
||||
"reason": "managed factor workload changed phase after deployment; this batch is retained for correctness only"
|
||||
},
|
||||
"businessContract": {
|
||||
"primaryTotalReturn": 0.9219861819172002,
|
||||
"primaryTradeCount": 26088,
|
||||
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"primaryResultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"secondaryTotalReturn": 1.1342962298106998,
|
||||
"secondaryTradeCount": 19404,
|
||||
"secondaryCanonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"secondaryResultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 423,
|
||||
"corePassed": 417,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/lazy-expression-scope-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/lazy-expression-scope-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/lazy-expression-scope-rollback-secondary-20260905.json"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "closure-based lazy bindings marginally helped one strategy but regressed the second stable five-year contract, so the implementation was removed"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+531
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+424
@@ -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,76 @@
|
||||
{
|
||||
"schemaVersion": "fidc-noalloc-instrument-board-rules/v1",
|
||||
"measuredAt": "2026-09-05T03:48:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "cfb19b5783cb446099cb3e4aff70cc39beec2e88",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
|
||||
"implementation": {
|
||||
"description": "evaluate KSH and BJSE order quantity rules with borrowed case-insensitive comparisons instead of allocating normalized board strings",
|
||||
"rulesChanged": false,
|
||||
"cacheAdded": false,
|
||||
"pitSemanticsChanged": false,
|
||||
"coveredBoards": ["KSH", "BJS", "BJ", "BJSE", "default"]
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"processCold": {
|
||||
"totalSeconds": 17.395,
|
||||
"dataSeconds": 12.879,
|
||||
"engineSeconds": 3.873
|
||||
},
|
||||
"processHotEngineSeconds": [3.861, 3.812, 3.896, 3.978, 3.924],
|
||||
"processHotMedianEngineSeconds": 3.896,
|
||||
"snapshotSourceBaselineMedianEngineSeconds": 3.91,
|
||||
"observedMedianImprovementPercent": 0.3580562659846607,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [3.519, 3.501, 3.582, 3.597],
|
||||
"medianEngineSeconds": 3.582,
|
||||
"snapshotSourceBaselineMedianEngineSeconds": 3.676,
|
||||
"observedMedianImprovementPercent": 2.5571273122959823,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788551290580_3858934_10",
|
||||
"engineSeconds": 4.283,
|
||||
"minimumOrderQuantityPercent": 0.21,
|
||||
"orderStepSizePercent": 0.11,
|
||||
"snapshotSourceBaselineMinimumOrderQuantityPercent": 1.28,
|
||||
"minimumOrderQuantityRelativeReductionPercent": 83.59375
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11477078016,
|
||||
"serviceCgroupPeakBytes": 11478847488,
|
||||
"processRssKiB": 11195964,
|
||||
"processAnonymousKiB": 11179928,
|
||||
"cacheMemoryAddedBytes": 0
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 422,
|
||||
"corePassed": 416,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/noalloc-instrument-board-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_generic_no_allocation_board_rules",
|
||||
"reason": "both five-year contracts preserve exact outputs, the primary does not regress, the secondary improves, the targeted profile hotspot falls, and no cache memory is added"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"schemaVersion": "fidc-numeric-vm-binding-generation-rejection/v1",
|
||||
"measuredAt": "2026-09-06T05:36:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"baseline": {
|
||||
"engineCommit": "5f08978",
|
||||
"primaryFiveYearHotMedianEngineSeconds": 2.629,
|
||||
"primaryFiveYearTotalReturn": 0.9219861819172002,
|
||||
"primaryFiveYearTradeCount": 26088,
|
||||
"primaryCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"primaryResultStoreSha256": "9ccf0c0fc6f5d72e974381ad4cd09a80241d7de99f2649f2b01736f7c80dc2c7",
|
||||
"profileInstructions": 16600366059,
|
||||
"profileBranches": 2592214949
|
||||
},
|
||||
"compileTimeIdentifierBinding": {
|
||||
"engineCommit": "2135a5b",
|
||||
"implementation": "map every numeric VM identifier to a typed runtime enum during expression plan compilation",
|
||||
"primaryFiveYearHotEngineSeconds": [2.608, 2.624, 2.651, 2.619, 2.781],
|
||||
"primaryFiveYearHotMedianEngineSeconds": 2.624,
|
||||
"observedMedianImprovementPercent": 0.190186,
|
||||
"profileInstructions": 16360894880,
|
||||
"instructionReductionPercent": 1.442566,
|
||||
"secondaryFiveYearResultConsistent": true,
|
||||
"secondaryFiveYearPerformanceExcluded": true,
|
||||
"secondaryFiveYearExclusionReason": "the symbolic campaign entered a high-memory-bandwidth phase between the primary and secondary batches",
|
||||
"netCodeLinesAdded": 562,
|
||||
"retained": false
|
||||
},
|
||||
"generationStampedScratchSlots": {
|
||||
"engineCommit": "5122c73",
|
||||
"implementation": "invalidate numeric VM variable and local slots with a generation counter instead of clearing Option arrays for each evaluation",
|
||||
"localReleaseBenchmark": {
|
||||
"baselineVmNanosecondsPerEvaluation": 86.992,
|
||||
"candidateSamples": [83.284, 94.166, 86.275, 84.138, 85.453],
|
||||
"candidateMedianNanosecondsPerEvaluation": 85.453,
|
||||
"componentImprovementPercent": 1.769136,
|
||||
"comparisonStrength": "weak because the baseline contains one sample"
|
||||
},
|
||||
"primaryFiveYearHotEngineSeconds": [2.634, 2.636, 2.608, 2.642, 2.710],
|
||||
"primaryFiveYearHotMedianEngineSeconds": 2.636,
|
||||
"observedMedianRegressionPercent": 0.266261,
|
||||
"profileInstructions": 16521146700,
|
||||
"instructionReductionPercent": 0.477215,
|
||||
"netCodeLinesAdded": 39,
|
||||
"retained": false
|
||||
},
|
||||
"hostLoad": {
|
||||
"symbolicWorkersObserved": 3,
|
||||
"symbolicWorkerCpuPercentApproximate": [720, 718, 698],
|
||||
"symbolicWorkerRssBytesApproximate": [54479982592, 53353455616, 53941170176],
|
||||
"wallTimeComparisonsAcrossPhasesExcluded": true
|
||||
},
|
||||
"testGate": {
|
||||
"workspacePassedBeforeFirstCandidateRejection": 539,
|
||||
"workspacePassedForGenerationCandidate": 540,
|
||||
"failed": 0,
|
||||
"ignoredManualBenchmarks": 8
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/goal-primary-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-primary-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-secondary-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-candidate-perf-stat-20260906.csv",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-rollback-primary-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/identifier-binding-rollback-perf-stat-20260906.csv",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/vm-generation-primary-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/vm-generation-candidate-perf-stat-20260906.csv"
|
||||
],
|
||||
"decision": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "both candidates preserved exact business results but failed to produce a material, stable end-to-end improvement; the typed binding added disproportionate code and the generation slots slightly regressed the five-year median",
|
||||
"nextTarget": "profile and specialize the numeric VM instruction dispatch or runtime helper execution without changing expression, PIT, or lazy short-circuit semantics"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schemaVersion": "fidc-numeric-vm-generation-scratch-rejection/v1",
|
||||
"measuredAt": "2026-09-05T04:34:00+08:00",
|
||||
"host": "local-macos",
|
||||
"candidateCommitted": false,
|
||||
"candidateDeployed": false,
|
||||
"candidate": {
|
||||
"description": "replace per-evaluation Option slot clearing with value arrays and u64 generation stamps",
|
||||
"iterations": 2000000,
|
||||
"vmNanosecondsPerEvaluation": 86.405,
|
||||
"rhaiNanosecondsPerEvaluation": 364.356
|
||||
},
|
||||
"baseline": {
|
||||
"iterations": 2000000,
|
||||
"vmNanosecondsPerEvaluation": 86.384,
|
||||
"rhaiNanosecondsPerEvaluation": 374.938
|
||||
},
|
||||
"observedVmRegressionPercent": 0.024310531,
|
||||
"targetedTestsPassed": 7,
|
||||
"acceptance": {
|
||||
"status": "rejected_and_removed_before_commit",
|
||||
"reason": "generation checks exactly offset slot initialization savings in the existing representative VM benchmark"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"schemaVersion": "fidc-post-hotpath-full-minute-regression/v1",
|
||||
"measuredAt": "2026-09-05T04:12:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "1f8a0fd",
|
||||
"runtimeCodeCommit": "cfb19b5783cb446099cb3e4aff70cc39beec2e88",
|
||||
"runnerBinarySha256": "3e69af42e41321d31c69b552cf22d7033ce1ea8d94305a32e32461148cdbfc60",
|
||||
"contract": {
|
||||
"startDate": "2025-01-02",
|
||||
"endDate": "2025-11-17",
|
||||
"frequency": "1m",
|
||||
"matchingType": "minute_last",
|
||||
"scheduleTime": "10:18:00",
|
||||
"slippageModel": "price_ratio",
|
||||
"slippageValue": 0.001,
|
||||
"commissionRate": 0.0001,
|
||||
"minimumCommission": 5.0,
|
||||
"stampTaxRate": 0.0005,
|
||||
"volumeLimit": false,
|
||||
"configuredVolumePercentInactive": 0.25
|
||||
},
|
||||
"bundle": {
|
||||
"oldBundleRejectedAsStale": true,
|
||||
"oldBundleId": "bt_bundle_d3109a7220681b850f31",
|
||||
"newBundleId": "bt_bundle_daadb1059454b30a9a6d",
|
||||
"newBundleHash": "14024f3774efb800575c7bd47b6a594b7b7fd7731493a1785c119ee7533378d2",
|
||||
"newDataEpoch": "strategy-factory-source-lake:scope-v1:3556e7dbadbdc94176d23a92acc78c84c50e2ec2a6d16b9dfd25978b1a98b2ae"
|
||||
},
|
||||
"result": {
|
||||
"totalReturn": 0.03473222656500008,
|
||||
"tradeCount": 156,
|
||||
"buyTradeCount": 79,
|
||||
"sellTradeCount": 77,
|
||||
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
|
||||
"oldCanonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"canonicalEqualAcrossDataEpoch": true,
|
||||
"terminalAuditStatus": "clean",
|
||||
"warnings": []
|
||||
},
|
||||
"performance": {
|
||||
"coldTotalSeconds": 106.497,
|
||||
"coldDataSeconds": 37.21,
|
||||
"coldEngineSeconds": 65.519,
|
||||
"coldRunContended": true,
|
||||
"hotTotalSeconds": 0.584,
|
||||
"hotDataSeconds": 0.004,
|
||||
"hotEngineSeconds": 0.162,
|
||||
"hotResultSeconds": 0.005,
|
||||
"hotFinalizationSeconds": 0.397
|
||||
},
|
||||
"whiteBoxAudit": {
|
||||
"status": "ok",
|
||||
"sourceRowsFormat": "arrow",
|
||||
"queriedSymbols": 1,
|
||||
"requiredPairs": 156,
|
||||
"queriedDailyBars": 156,
|
||||
"queriedMinuteBars": 156,
|
||||
"slippagePriceChecks": 156,
|
||||
"missingExecutionTimestamps": 0,
|
||||
"executionTimestamp": "each trade date at 10:18:00",
|
||||
"failureCounts": {},
|
||||
"warningCounts": {},
|
||||
"firstBuy": {
|
||||
"symbol": "000001.SZ",
|
||||
"rawMinuteClose": 11.55,
|
||||
"fillPrice": 11.56155,
|
||||
"quantity": 43200,
|
||||
"grossAmount": 499458.96,
|
||||
"commission": 49.945896,
|
||||
"stampTax": 0.0
|
||||
},
|
||||
"lastSell": {
|
||||
"symbol": "000001.SZ",
|
||||
"rawMinuteClose": 11.77,
|
||||
"fillPrice": 11.75823,
|
||||
"quantity": 400,
|
||||
"grossAmount": 4703.292,
|
||||
"commission": 5.0,
|
||||
"stampTax": 2.351646
|
||||
}
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/post-daily-hotpath-full-minute-current-bundle-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/post-daily-hotpath-full-minute-audit-20260905/audit.json"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "passed",
|
||||
"reason": "the current Source Lake generation reproduces the historical canonical result and every fill passes minute timestamp, raw-price, slippage, fee, tax, and terminal-state audit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"schemaVersion": "fidc-rank-expression-presence/v1",
|
||||
"measuredAt": "2026-09-05T12:50:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "42999ffa2cb6967315dc4c97220561c0611cda22",
|
||||
"featureCommit": "c225d8484f0493a473b68c11389513d5167ba4cc",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "3060dd5afe720bccd1d2f959f6af04af6325766f8c6829cd80e5af9785ec2346",
|
||||
"implementation": {
|
||||
"description": "freeze rank expression presence at strategy construction and remove repeated rank_expr trim checks in selection dispatch",
|
||||
"strategyCodeChangeRecompilesRequirement": true,
|
||||
"rankValuesCached": false,
|
||||
"selectionResultsCached": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"previousAcceptedEngineMedianSeconds": 2.611,
|
||||
"engineSeconds": [2.439, 2.447, 2.435, 2.442, 2.441, 2.432],
|
||||
"processHotMedianEngineSeconds": 2.441,
|
||||
"observedImprovementPercent": 6.510915,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"previousAcceptedEngineMedianSeconds": 2.346,
|
||||
"engineSeconds": [2.191, 2.165, 2.249, 2.17, 2.159, 2.18],
|
||||
"processHotMedianEngineSeconds": 2.17,
|
||||
"observedImprovementPercent": 7.502131,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"genericRankFiveYearContract": {
|
||||
"rankBy": "free_float_cap",
|
||||
"totalReturn": 0.7140315244542004,
|
||||
"tradeCount": 21876,
|
||||
"canonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
|
||||
"resultStoreSha256": "e2c7401bb9950da6fdfc0b516428061651e3e5b874a5f80c4b03ab8a81e90191",
|
||||
"lowLoadEngineSeconds": [3.529, 3.472, 3.514, 3.465, 3.455, 3.459],
|
||||
"processHotMedianEngineSeconds": 3.465,
|
||||
"sameLoadCausalComparison": "not_claimed",
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"fullMinuteContract": {
|
||||
"scheduleTime": "10:18",
|
||||
"matchingType": "minute_last",
|
||||
"slippageModel": "price_ratio",
|
||||
"slippageValue": 0.001,
|
||||
"totalReturn": 0.03473222656500008,
|
||||
"tradeCount": 156,
|
||||
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
|
||||
"engineSeconds": [0.157, 0.16],
|
||||
"processHotMedianEngineSeconds": 0.16,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"testGate": {
|
||||
"workspaceTotal": 544,
|
||||
"passed": 536,
|
||||
"ignoredManualBenchmarks": 8,
|
||||
"failed": 0
|
||||
},
|
||||
"memory": {
|
||||
"persistentCacheAddedBytes": 0,
|
||||
"currentServiceCgroupBytesAfterRestart": 23715840
|
||||
},
|
||||
"acceptance": {
|
||||
"status": "accepted_compile_time_rank_presence",
|
||||
"reason": "both independent five-year ordered contracts improved in the low-load runs, generic rank stayed business-identical without a causal wall-time claim, and full-minute execution stayed identical"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"schemaVersion": "fidc-rolling-boundary-reuse-rejection/v1",
|
||||
"measuredAt": "2026-09-05T12:40:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "6b5d576",
|
||||
"rollbackCommit": "b55ac0bf814641ec09f61f4ec10d52b311ee8aab",
|
||||
"finalRemovalCommit": "b55ac0bf814641ec09f61f4ec10d52b311ee8aab",
|
||||
"implementation": {
|
||||
"description": "share one adjusted-close base factor and one valid-volume count across batched rolling windows",
|
||||
"businessResultsChanged": false,
|
||||
"retainedInProduction": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"candidateEngineSeconds": [2.6, 2.557, 2.564, 2.603, 2.574, 2.614],
|
||||
"candidateProcessHotMedianEngineSeconds": 2.574,
|
||||
"acceptedBaselineEngineSeconds": 2.441,
|
||||
"observedRegressionPercent": 5.448587,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"tradeCount": 26088
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"candidateEngineSeconds": [2.381, 2.323, 2.344, 2.377, 2.353, 2.348],
|
||||
"candidateProcessHotMedianEngineSeconds": 2.348,
|
||||
"acceptedBaselineEngineSeconds": 2.17,
|
||||
"observedRegressionPercent": 8.202765,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"tradeCount": 19404
|
||||
},
|
||||
"acceptance": {
|
||||
"status": "rejected_cross_strategy_regression",
|
||||
"reason": "both real five-year controls became slower despite exact result parity; candidate code and production binary were removed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schemaVersion": "fidc-selection-band-precheck-rejection/v1",
|
||||
"measuredAt": "2026-09-05T12:55:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "e060af380ebfce6d9e802bb6e9677c11835fc0d0",
|
||||
"removalCommit": "42999ffa2cb6967315dc4c97220561c0611cda22",
|
||||
"implementation": {
|
||||
"description": "evaluate factor-backed market-cap band bounds before constructing StockExpressionState",
|
||||
"genericRankBenefitObserved": true,
|
||||
"orderedControlRegressionObserved": true,
|
||||
"retainedInProduction": false
|
||||
},
|
||||
"genericRankFiveYearContract": {
|
||||
"rankBy": "free_float_cap",
|
||||
"candidateProcessHotMedianEngineSeconds": 2.835,
|
||||
"acceptedBaselineProcessHotMedianEngineSeconds": 3.465,
|
||||
"observedImprovementPercent": 18.181818,
|
||||
"canonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
|
||||
"tradeCount": 21876
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"candidateProcessHotMedianEngineSeconds": 2.565,
|
||||
"acceptedBaselineProcessHotMedianEngineSeconds": 2.441,
|
||||
"observedRegressionPercent": 5.079148,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"tradeCount": 26088
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"candidateProcessHotMedianEngineSeconds": 2.3465,
|
||||
"acceptedBaselineProcessHotMedianEngineSeconds": 2.17,
|
||||
"observedRegressionPercent": 8.133641,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"tradeCount": 19404
|
||||
},
|
||||
"fullMinuteRollbackContract": {
|
||||
"tradeCount": 156,
|
||||
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"acceptance": {
|
||||
"status": "rejected_cross_strategy_regression",
|
||||
"reason": "single generic-rank improvement cannot justify regressions in both standard ordered controls; candidate was removed from local, remote and deployed code"
|
||||
}
|
||||
}
|
||||
@@ -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,93 @@
|
||||
{
|
||||
"schemaVersion": "fidc-shared-market-cap-order-index-ab/v1",
|
||||
"measuredAt": "2026-09-05T00:43:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "05953f857a3e3844595bb63a684c34a2e44cbaaf",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "188269bf0c2ee65d6c11ed37ddddfdaf86695941e8123f69f950054bea39796e",
|
||||
"implementation": {
|
||||
"description": "build an immutable per-date u32 symbol-id order by market cap in parallel with DataSet construction and reuse it across strategy runs",
|
||||
"riskEvaluationOrderPreserved": true,
|
||||
"riskDiagnosticsOrderPreserved": true,
|
||||
"selectionOrderPreserved": true,
|
||||
"cachedFactorValues": false,
|
||||
"cachedSelectionResults": false,
|
||||
"cachedAccountOrOrderState": false,
|
||||
"pitSemanticsChanged": false,
|
||||
"adjustmentSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"firstRun": {
|
||||
"runId": "btr_1788540070941_2799354_0",
|
||||
"totalSeconds": 19.825,
|
||||
"dataSeconds": 13.141,
|
||||
"engineSeconds": 6.024
|
||||
},
|
||||
"hotRuns": [
|
||||
{"runId": "btr_1788540095588_2799354_1", "totalSeconds": 6.664, "engineSeconds": 5.528},
|
||||
{"runId": "btr_1788540107127_2799354_2", "totalSeconds": 6.546, "engineSeconds": 5.742},
|
||||
{"runId": "btr_1788540118939_2799354_3", "totalSeconds": 6.586, "engineSeconds": 5.643},
|
||||
{"runId": "btr_1788540129894_2799354_4", "totalSeconds": 6.591, "engineSeconds": 5.929},
|
||||
{"runId": "btr_1788540141992_2799354_5", "totalSeconds": 6.794, "engineSeconds": 6.145}
|
||||
],
|
||||
"hotMedianEngineSeconds": 5.742,
|
||||
"restoredBaselineHotMedianEngineSeconds": 6.702,
|
||||
"observedMedianImprovementPercent": 14.324082363473588,
|
||||
"terminalAuditStatus": "clean",
|
||||
"resultConsistent": true
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"description": "same frozen source and execution contract with target positions changed from 30 to 20",
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [5.699, 5.545, 5.604, 5.23],
|
||||
"processHotMedianEngineSeconds": 5.545,
|
||||
"terminalAuditStatus": "clean",
|
||||
"resultConsistent": true
|
||||
},
|
||||
"memory": {
|
||||
"baselineCurrentBytes": 11426254848,
|
||||
"candidateCurrentBytes": 11433934848,
|
||||
"candidatePeakBytes": 11435282432,
|
||||
"observedIncreaseBytes": 7680000
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788540249539_2799354_10",
|
||||
"totalSeconds": 6.35,
|
||||
"engineSeconds": 5.696,
|
||||
"eventCycles": 15734772698,
|
||||
"stockStatePercent": 23.86,
|
||||
"stableUniverseSortPresentInTopProfile": false,
|
||||
"numericVmPercent": 5.29,
|
||||
"adjustedCloseMovingAveragePercent": 4.71,
|
||||
"mallocPercent": 4.33
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 419,
|
||||
"corePassed": 413,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/shared-market-cap-order-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/shared-market-cap-order-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/shared-market-cap-order-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/shared-market-cap-order-profile-20260905/perf-report.txt",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/shared-market-cap-order-profile-20260905/run.json"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_shared_immutable_index_end_to_end_no_regression",
|
||||
"reason": "both five-year strategies preserve exact business outputs, the primary stable hot median improves by about 14.3 percent, first-build data time remains at the prior process-cold baseline, and resident memory increases by only about 7.7 MB"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schemaVersion": "fidc-signal-rolling-scan-rejection/v1",
|
||||
"measuredAt": "2026-09-05T13:45:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "b65b3ed8f1739f7df84c50ae7993ca2ed3d5857e",
|
||||
"removalCommit": "2aa330786aa45ccdfa88c2dc8a65821d8d7a9606",
|
||||
"implementation": {
|
||||
"description": "replace materialized signal_close values and return values with direct current-series scans for rolling max and return sample standard deviation",
|
||||
"businessResultsChanged": false,
|
||||
"retainedInProduction": false
|
||||
},
|
||||
"genericRankFiveYearContract": {
|
||||
"rankBy": "free_float_cap",
|
||||
"candidateEngineSeconds": [3.707, 3.704, 3.7, 3.736, 3.699, 3.721],
|
||||
"candidateProcessHotMedianEngineSeconds": 3.704,
|
||||
"acceptedBaselineProcessHotMedianEngineSeconds": 3.465,
|
||||
"observedRegressionPercent": 6.897547,
|
||||
"canonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
|
||||
"tradeCount": 21876
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"candidateEngineSeconds": [2.624, 2.607, 2.604, 2.601, 2.61],
|
||||
"candidateProcessHotMedianEngineSeconds": 2.6055,
|
||||
"acceptedBaselineProcessHotMedianEngineSeconds": 2.441,
|
||||
"observedRegressionPercent": 6.739041,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"tradeCount": 26088
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"candidateEngineSeconds": [2.349, 2.336, 2.39, 2.365, 2.353],
|
||||
"candidateProcessHotMedianEngineSeconds": 2.359,
|
||||
"acceptedBaselineProcessHotMedianEngineSeconds": 2.17,
|
||||
"observedRegressionPercent": 8.709677,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"tradeCount": 19404
|
||||
},
|
||||
"rollbackFullMinuteContract": {
|
||||
"tradeCount": 156,
|
||||
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"acceptance": {
|
||||
"status": "rejected_cross_strategy_regression",
|
||||
"reason": "direct scans were slower for generic rank and both daily controls despite exact output parity; candidate and deployed binary were removed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schemaVersion": "fidc-specialized-stock-snapshot-source/v1",
|
||||
"measuredAt": "2026-09-05T03:38:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "d79678d8509b40ec085f2fa0a7c1e7cb90c95810",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "3f9ce258d3b055097b0cb9e4ae4df423b3d584420befae4d85cdd7ccc51e38bf",
|
||||
"implementation": {
|
||||
"description": "monomorphize indexed and daily-view stock snapshot sources, reuse execution/factor DailySnapshotView in selection loops, and keep the shared stock-state builder free of runtime Optional lookup branches",
|
||||
"eligibleUniverseRowChanged": false,
|
||||
"runtimeOptionalViewBranch": false,
|
||||
"selectionResultCached": false,
|
||||
"factorValueCached": false,
|
||||
"pitSemanticsChanged": false,
|
||||
"componentBenchmark": {
|
||||
"rows": 6000,
|
||||
"rounds": 200,
|
||||
"indexedSeconds": 0.008542,
|
||||
"dailyViewSeconds": 0.002821,
|
||||
"speedup": 3.027294
|
||||
}
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"processCold": {
|
||||
"totalSeconds": 17.62,
|
||||
"dataSeconds": 13.03,
|
||||
"engineSeconds": 3.976
|
||||
},
|
||||
"processHotEngineSeconds": [4.085, 3.91, 3.893, 4.125, 3.874],
|
||||
"processHotMedianEngineSeconds": 3.91,
|
||||
"typedHelperBaselineMedianEngineSeconds": 4.729,
|
||||
"observedMedianImprovementPercent": 17.318672023683654,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [3.672, 3.655, 3.676, 3.689],
|
||||
"medianEngineSeconds": 3.676,
|
||||
"typedHelperBaselineMedianEngineSeconds": 4.049,
|
||||
"observedMedianImprovementPercent": 9.212151148431715,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788550694645_3730056_10",
|
||||
"engineSeconds": 4.278,
|
||||
"allThreadEventCycles": 95711324146,
|
||||
"comparisonAllThreadEventCycles": 110359308122,
|
||||
"observedCycleReductionPercent": 13.272993665207602,
|
||||
"marketBySymbolIdPercent": 1.69,
|
||||
"factorBySymbolIdPercent": 0.13,
|
||||
"candidateBySymbolIdPercent": 0.32,
|
||||
"comparisonMarketBySymbolIdPercent": 3.46,
|
||||
"comparisonFactorBySymbolIdPercent": 2.66,
|
||||
"comparisonCandidateBySymbolIdPercent": 2.51
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11498143744,
|
||||
"serviceCgroupPeakBytes": 11501256704,
|
||||
"processRssKiB": 11216788,
|
||||
"processAnonymousKiB": 11200492,
|
||||
"typedHelperBaselineCgroupCurrentBytes": 11495362560,
|
||||
"observedCgroupIncreaseBytes": 2781184
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 421,
|
||||
"corePassed": 415,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/specialized-stock-snapshot-source-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/specialized-stock-snapshot-source-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/specialized-stock-snapshot-source-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/specialized-stock-snapshot-source-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_generic_monomorphized_snapshot_source",
|
||||
"reason": "both five-year strategies preserve exact outputs and improve stable medians, lookup hotspots fall materially, and the implementation avoids the enlarged row and runtime Optional branches that caused the prior rejected design"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"schemaVersion": "fidc-stock-snapshot-field-projection/v1",
|
||||
"measuredAt": "2026-09-05T02:11:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "7f17fa1fb427fefe5c70c9170fe98e2a8761e3ac",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "0c0ea8629afa789024a6212bcb7ac0e8291f6a88943dd9f7bee16d4902c96be1",
|
||||
"implementation": {
|
||||
"description": "compile per-strategy requirements for amount and upper/lower limit-touch fields, then skip their factor-map lookups and fallback calculations when no expression consumes them",
|
||||
"dynamicFactorMapStillLoadsAllFields": true,
|
||||
"explicitActionsStillLoadAllFields": true,
|
||||
"factorValuesCached": false,
|
||||
"selectionResultsCached": false,
|
||||
"pitSemanticsChanged": false,
|
||||
"executionSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"processCold": {
|
||||
"totalSeconds": 18.368,
|
||||
"dataSeconds": 12.691,
|
||||
"engineSeconds": 4.952
|
||||
},
|
||||
"processHotEngineSeconds": [5.434, 4.762, 5.269, 5.366, 5.356],
|
||||
"processHotMedianEngineSeconds": 5.356,
|
||||
"compactKeyBaselineMedianEngineSeconds": 5.734,
|
||||
"observedMedianImprovementPercent": 6.592256714335544,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"lowContentionEngineSeconds": [4.867, 4.36],
|
||||
"highContentionEngineSecondsExcluded": [32.561, 24.001],
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"hardwareCounters": {
|
||||
"candidateRunId": "btr_1788545432236_3086752_11",
|
||||
"candidateEngineSeconds": 5.291,
|
||||
"candidateInstructions": 26663937176,
|
||||
"candidateBranches": 4751846020,
|
||||
"candidateBranchMisses": 21422590,
|
||||
"internedSymbolBaselineInstructions": 27997441984,
|
||||
"internedSymbolBaselineBranches": 4947191270,
|
||||
"observedInstructionReductionPercent": 4.762952303864304,
|
||||
"observedBranchReductionPercent": 3.9486092075029067
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788545382848_3086752_10",
|
||||
"engineSeconds": 5.009,
|
||||
"eventCycles": 13976422355,
|
||||
"stockStateSelfPercent": 12.52,
|
||||
"internedSymbolBaselineStockStatePercent": 16.25,
|
||||
"stockStateRelativeReductionPercent": 22.953846153846154,
|
||||
"factorMapBTreeGetBaselinePercent": 4.0,
|
||||
"factorMapBTreeGetPresentInCandidateTopProfile": false
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 421,
|
||||
"corePassed": 415,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/stock-snapshot-field-projection-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/stock-snapshot-field-projection-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/stock-snapshot-field-projection-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/stock-snapshot-field-projection-profile-20260905/perf-report.txt",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/stock-snapshot-field-projection-perf-stat-20260905/perf-stat.csv"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_generic_compiled_field_projection",
|
||||
"reason": "two distinct five-year contracts preserve exact business outputs and terminal audits, while the primary low-contention median, hardware counters, and profile all show less work without adding a shared mutable cache"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"schemaVersion": "fidc-stock-state-calendar-index-reuse-ab/v1",
|
||||
"measuredAt": "2026-09-05T00:02:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "6d458dbbc6fa3acb1bff0824307281c82170031a",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"serviceBinarySha256": "bcd918407de2aa3fdc230d8e9976be5cbf5d3b53185f2a528d55beb10f5f1552",
|
||||
"runnerBinarySha256": "e33dd75aa7b481975c0991cf634d07900cdefa51756802e28e64f18b980a83dc",
|
||||
"implementation": {
|
||||
"description": "resolve the trading-calendar index once when the per-day stock-state cache advances, then reuse it for decision and current rolling-series boundaries",
|
||||
"cachedFactorValues": false,
|
||||
"cachedSelectionResults": false,
|
||||
"cachedOrderState": false,
|
||||
"pitSemanticsChanged": false,
|
||||
"adjustmentSemanticsChanged": false,
|
||||
"fallback": "dates outside the indexed calendar retain the existing date-based rolling fallback"
|
||||
},
|
||||
"componentBenchmark": {
|
||||
"samples": 6,
|
||||
"iterationsPerSample": 5000000,
|
||||
"dateLookupSeconds": 0.400282218,
|
||||
"reusedCalendarIndexSeconds": 0.070751718,
|
||||
"speedup": 5.657561813552005,
|
||||
"checksum": 90000000,
|
||||
"equal": true
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 419,
|
||||
"corePassed": 413,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"beforeEngineSeconds": [6.764, 6.391, 12.387],
|
||||
"beforeMedianEngineSeconds": 6.764,
|
||||
"afterHotEngineSeconds": [6.224, 6.497, 6.405, 6.539, 6.386],
|
||||
"afterMedianEngineSeconds": 6.405,
|
||||
"observedMedianImprovementPercent": 5.307510348905976,
|
||||
"benchmarkSummaryProcessHotMedianSeconds": {
|
||||
"before": 9.389,
|
||||
"after": 6.451
|
||||
},
|
||||
"note": "The explicit all-run medians are reported above. The benchmark tool excludes its first run when calculating processHotMedian; concurrent non-FIDC load makes the tool summary less comparable than the complete sample list."
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"description": "same frozen source and execution contract with target positions changed from 30 to 20",
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [6.217, 9.279, 10.771],
|
||||
"resultConsistent": true,
|
||||
"performanceClaimed": false,
|
||||
"reason": "two runs overlapped high external host contention; the strategy is retained as cross-strategy semantic evidence only"
|
||||
},
|
||||
"excludedHighContentionBatch": {
|
||||
"hostLoadAverageObserved": [65.08, 44.49, 35.56],
|
||||
"engineSeconds": [11.601, 12.061, 33.904, 23.149, 7.391],
|
||||
"resultConsistent": true,
|
||||
"performanceComparisonExcluded": true,
|
||||
"reason": "independent tan processes and active FIDC symbolic workers caused material host scheduling contention; no external process was modified"
|
||||
},
|
||||
"runtime": {
|
||||
"servicePid": 2548679,
|
||||
"serviceUser": "boris",
|
||||
"serviceState": "active/running",
|
||||
"allowedCpus": "0 2 4 6 8 10 12 14 48 50 52 54 56 58 60 62",
|
||||
"memoryCurrentBytes": 11426254848,
|
||||
"memoryPeakBytes": 11427790848,
|
||||
"maxConcurrentRuns": 1,
|
||||
"runnerThreadsPerRun": 8
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-index-before-20260904.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-index-after-20260904.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-index-after-hot-20260904.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/calendar-index-secondary-20260905.json"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_component_and_primary_end_to_end_no_regression",
|
||||
"reason": "the component path is materially faster, the stable primary five-year batch improves, and both frozen business contracts preserve exact returns, trade counts, canonical digests, result-store digests, and clean terminal audits"
|
||||
}
|
||||
}
|
||||
@@ -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,111 @@
|
||||
{
|
||||
"schemaVersion": "fidc-symbol-id-ranked-candidates/v1",
|
||||
"measuredAt": "2026-09-05T06:23:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "52f9ee9d92781c1367ba4f01cbc3b1676fdd241e",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "c567c6da3d3625839a1d0127f755ccd278f31473924bf81ab837158370c08667",
|
||||
"implementation": {
|
||||
"description": "rank custom and quote-plan candidates as (symbol_id, rank, state_index), borrow symbols only at diagnostics and output boundaries, and use the lexical symbol-id order as the exact equal-rank tie breaker",
|
||||
"symbolIdContract": "DataSet assigns u32 ids after sorting the complete normalized symbol set lexicographically",
|
||||
"candidateSymbolClonesRemoved": true,
|
||||
"selectionResultCached": false,
|
||||
"persistentCacheAdded": false,
|
||||
"rankTieSemanticsChanged": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"rejectedIntermediate": {
|
||||
"commit": "d7e11be",
|
||||
"problem": "looked up both factor-row strings inside every equal-rank comparator call even though symbol ids already encode lexical order",
|
||||
"engineSeconds": [6.056, 6.274, 5.298, 6.071, 6.066, 5.957],
|
||||
"retainedInFinalCode": false
|
||||
},
|
||||
"genericRankFiveYearContract": {
|
||||
"rankBy": "free_float_cap",
|
||||
"totalReturn": 0.7140315244542004,
|
||||
"tradeCount": 21876,
|
||||
"canonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
|
||||
"resultStoreSha256": "e2c7401bb9950da6fdfc0b516428061651e3e5b874a5f80c4b03ab8a81e90191",
|
||||
"arenaBaselineMedianEngineSeconds": 4.222,
|
||||
"engineSeconds": [3.809, 3.685, 3.739, 3.743, 3.76, 3.752],
|
||||
"processHotMedianEngineSeconds": 3.743,
|
||||
"incrementalImprovementPercent": 11.345334,
|
||||
"originalCachedBaselineMedianEngineSeconds": 5.934,
|
||||
"cumulativeImprovementPercent": 36.922818,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"primaryOrderedFiveYearContract": {
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"previousProcessHotMedianEngineSeconds": 2.73,
|
||||
"processHotMedianEngineSeconds": 2.611,
|
||||
"observedImprovementPercent": 4.358974,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryOrderedFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"previousProcessHotMedianEngineSeconds": 2.372,
|
||||
"processHotMedianEngineSeconds": 2.346,
|
||||
"observedImprovementPercent": 1.096121,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"fullMinuteContract": {
|
||||
"scheduleTime": "10:18",
|
||||
"matchingType": "minute_last",
|
||||
"slippageModel": "price_ratio",
|
||||
"slippageValue": 0.001,
|
||||
"totalReturn": 0.03473222656500008,
|
||||
"tradeCount": 156,
|
||||
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
|
||||
"totalSeconds": [0.608, 0.608],
|
||||
"engineSeconds": [0.161, 0.167],
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788560495042_846890_19",
|
||||
"engineSeconds": 4.007,
|
||||
"allThreadEventCycles": 86456699303,
|
||||
"transientStockStateBuilderPercent": 18.97,
|
||||
"numericVmEvaluatePercent": 8.0,
|
||||
"rankedSelectionPercent": 7.6,
|
||||
"adjustedCloseMovingAveragePercent": 5.38,
|
||||
"stableSortPercent": 3.09,
|
||||
"mallocPercent": 1.19,
|
||||
"arenaBaselineMallocPercent": 3.34,
|
||||
"lostSamples": 0
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11537428480,
|
||||
"serviceCgroupPeakBytes": 11538923520,
|
||||
"persistentCacheAddedBytes": 0
|
||||
},
|
||||
"testGate": {
|
||||
"workspaceTotal": 544,
|
||||
"passed": 536,
|
||||
"ignoredManualBenchmarks": 8,
|
||||
"failed": 0,
|
||||
"lexicalSymbolIdRegression": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-rank-tie-fixed-generic-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-rank-tie-fixed-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-rank-tie-fixed-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-rank-tie-fixed-full-minute-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-rank-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-rank-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_symbol_id_ranked_candidates",
|
||||
"reason": "the final implementation preserves exact rank ties and all business outputs, improves the generic five-year median by 11.35 percent over the arena baseline and 36.92 percent over the old cached path, improves both ordered controls, and lowers malloc share without persistent memory"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schemaVersion": "fidc-symbol-id-selection-stream/v1",
|
||||
"measuredAt": "2026-09-05T04:53:13+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "0e3c2028d0b0a26a2d3b8e2fb9dd0765c60809b1",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "386e5542d66bc6850f9569dc25d061f043103df279d3aba5e86343a72d5113f9",
|
||||
"implementation": {
|
||||
"description": "scan signal-day selection risk into a market-cap-ordered u32 symbol-id stream and materialize EligibleUniverseSnapshot rows only for strategies that require the generic ranking path",
|
||||
"fastPathContract": "ascending market_cap or market_cap_bn rank with daily_replacement_limit=0",
|
||||
"genericRankingChanged": false,
|
||||
"selectionResultCached": false,
|
||||
"factorValueCached": false,
|
||||
"riskDecisionOrderChanged": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"restartOrCold": {
|
||||
"totalSeconds": 19.223,
|
||||
"dataSeconds": 14.395,
|
||||
"engineSeconds": 4.182
|
||||
},
|
||||
"processHotEngineSeconds": [3.438, 3.185, 3.346, 3.288, 3.294],
|
||||
"processHotMedianEngineSeconds": 3.294,
|
||||
"previousAcceptedMedianEngineSeconds": 3.896,
|
||||
"observedMedianImprovementPercent": 15.451745,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [2.871, 2.897, 2.899, 2.964, 2.948, 2.922],
|
||||
"medianEngineSeconds": 2.922,
|
||||
"previousAcceptedMedianEngineSeconds": 3.582,
|
||||
"observedMedianImprovementPercent": 18.425461,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788555090941_297399_12",
|
||||
"engineSeconds": 3.37,
|
||||
"allThreadEventCycles": 77490041805,
|
||||
"stockStateBySymbolIdPercent": 13.59,
|
||||
"numericVmEvaluatePercent": 8.27,
|
||||
"adjustedCloseMovingAveragePercent": 4.74,
|
||||
"selectionSymbolIdScanPercent": 3.53,
|
||||
"selectSymbolsPercent": 3.26,
|
||||
"stringTrimPercent": 3.14,
|
||||
"mallocPercent": 2.82,
|
||||
"lostSamples": 0
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11481071616,
|
||||
"serviceCgroupPeakBytes": 11488108544,
|
||||
"cacheMemoryAddedBytes": 0
|
||||
},
|
||||
"testGate": {
|
||||
"workspaceTotal": 544,
|
||||
"passed": 536,
|
||||
"ignoredManualBenchmarks": 8,
|
||||
"failed": 0
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-selection-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-selection-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-selection-profile-20260905/run.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-selection-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-selection-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_generic_symbol_id_selection_stream",
|
||||
"reason": "both independent five-year contracts preserve exact results and improve stable engine medians materially without adding cache memory or changing generic ranking, risk ordering, or PIT semantics"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schemaVersion": "fidc-symbol-id-series-storage-rejection/v1",
|
||||
"measuredAt": "2026-09-06T06:31:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"candidateCommit": "5a7c49a4543b584503ff0d2c2ae513c68d25de8a",
|
||||
"implementation": "remove duplicate string-keyed market and adjusted-close series maps and build symbol-id vectors directly",
|
||||
"businessParity": {
|
||||
"primaryFiveYearCanonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"secondaryFiveYearCanonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"genericRankFiveYearCanonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
|
||||
"minuteCanonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"allTerminalAuditsClean": true,
|
||||
"allResultsConsistent": true
|
||||
},
|
||||
"performance": {
|
||||
"baselineDatasetConstructSeconds": [4.448, 4.546],
|
||||
"candidateEarlyDatasetConstructSeconds": [3.899, 3.895, 3.663],
|
||||
"candidateFinalRestartDatasetConstructSeconds": 4.472,
|
||||
"candidateFinalRestartColdDataSeconds": 14.513,
|
||||
"candidateFinalRestartColdTotalSeconds": 18.381,
|
||||
"conclusion": "the apparent early improvement did not reproduce after a final restart under the current host phase; the final constructor time is equal to the adjacent baseline range"
|
||||
},
|
||||
"memory": {
|
||||
"baselineSingleDatasetCurrentBytes": 11470495744,
|
||||
"candidateSingleDatasetCurrentBytes": 11473674240,
|
||||
"conclusion": "no measurable resident-memory reduction"
|
||||
},
|
||||
"testGate": {
|
||||
"corePassed": 538,
|
||||
"ignoredManualBenchmarks": 8,
|
||||
"failed": 0
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-series-primary-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-series-secondary-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-series-generic-rank-five-year-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/symbol-id-series-minute-20260906.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/final-symbol-id-series-post-doc-sync-20260906.json"
|
||||
],
|
||||
"decision": {
|
||||
"status": "rejected_and_removed",
|
||||
"reason": "the candidate preserved correctness but did not provide a stable end-to-end or memory improvement across restart validation; duplicate maps are not a proven material bottleneck",
|
||||
"nextTarget": "remove the SourceRowRecord to DailySnapshot to DataSet multi-stage materialization, or publish a content-addressed base panel that can be mapped across restarts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"schemaVersion": "fidc-transient-selection-arena/v1",
|
||||
"measuredAt": "2026-09-05T05:57:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "d89dd24f0a3993ed10b00957f947b65d91b00ffc",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "53680c33cd96487c52c47b96bb8c6f0bece69bb5617a6036d382f4262c181b2c",
|
||||
"implementation": {
|
||||
"description": "keep custom-rank and quote-plan stock states in a contiguous transient arena, sort lightweight state indexes, and isolate ordered and generic selection functions",
|
||||
"candidateSnapshotCacheUsed": false,
|
||||
"selectionResultCached": false,
|
||||
"stateArenaSharedAcrossRuns": false,
|
||||
"stateArenaLifetime": "single selection call",
|
||||
"riskDecisionOrderChanged": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"rejectedIntermediate": {
|
||||
"commit": "7f7fce1",
|
||||
"problem": "stored StockExpressionState directly in the sortable tuple and repeatedly moved the large value during stable sorting",
|
||||
"rollbackBaselineEngineSeconds": 5.934,
|
||||
"observedBadCandidateEngineSeconds": [6.972, 8.868, 8.748, 9.317, 6.251],
|
||||
"retainedInFinalCode": false
|
||||
},
|
||||
"genericRankFiveYearContract": {
|
||||
"requestSha256": "06a5cdf6a96673d45cc54f4ff0f18a125dbcc0b80d0d52496f8a0c638bbc81d3",
|
||||
"rankBy": "free_float_cap",
|
||||
"totalReturn": 0.7140315244542004,
|
||||
"tradeCount": 21876,
|
||||
"canonicalSha256": "84367993911b42437939aad88f29379d007af6f5f171667ff7ad6677dd488fd2",
|
||||
"resultStoreSha256": "e2c7401bb9950da6fdfc0b516428061651e3e5b874a5f80c4b03ab8a81e90191",
|
||||
"rollbackMedianEngineSeconds": 5.934,
|
||||
"finalEngineSeconds": [4.143, 4.125, 4.218, 4.226, 4.379],
|
||||
"finalMedianEngineSeconds": 4.222,
|
||||
"observedMedianImprovementPercent": 28.850691,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"primaryOrderedFiveYearContract": {
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"engineSeconds": [2.647, 2.692, 2.659, 2.813, 2.768],
|
||||
"allRunMedianEngineSeconds": 2.692,
|
||||
"previousAcceptedMedianEngineSeconds": 2.667,
|
||||
"observedMedianDeltaPercent": 0.937383,
|
||||
"classification": "within_host_load_variance",
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryOrderedFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"rollbackMedianEngineSeconds": 2.408,
|
||||
"finalProcessHotMedianEngineSeconds": 2.372,
|
||||
"observedMedianImprovementPercent": 1.495017,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"fullMinuteContract": {
|
||||
"scheduleTime": "10:18",
|
||||
"matchingType": "minute_last",
|
||||
"slippageModel": "price_ratio",
|
||||
"slippageValue": 0.001,
|
||||
"totalReturn": 0.03473222656500008,
|
||||
"tradeCount": 156,
|
||||
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
|
||||
"totalSeconds": [0.6, 0.546],
|
||||
"engineSeconds": [0.155, 0.164],
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"genericRankProfile": {
|
||||
"runId": "btr_1788558913084_672102_18",
|
||||
"engineSeconds": 4.46,
|
||||
"allThreadEventCycles": 33149272847,
|
||||
"transientStockStateBuilderPercent": 16.1,
|
||||
"numericVmEvaluatePercent": 8.53,
|
||||
"rankedSelectionPercent": 6.8,
|
||||
"adjustedCloseMovingAveragePercent": 4.87,
|
||||
"stableSortPercent": 3.68,
|
||||
"mallocPercent": 3.34,
|
||||
"lostSamples": 0
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11510001664,
|
||||
"serviceCgroupPeakBytes": 11511304192,
|
||||
"persistentCacheAddedBytes": 0
|
||||
},
|
||||
"testGate": {
|
||||
"workspaceTotal": 544,
|
||||
"passed": 536,
|
||||
"ignoredManualBenchmarks": 8,
|
||||
"failed": 0,
|
||||
"quotePlanTransientCacheAssertions": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/free-float-rank-five-year-request-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-rollback-generic-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-final-generic-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-final-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-rollback-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-final-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-final-full-minute-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-final-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/transient-selection-final-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_generic_transient_selection_arena",
|
||||
"reason": "the same frozen custom-rank contract is about 28.85 percent faster than an immediate rollback, both ordered five-year contracts and full-minute execution preserve exact outputs, the final design sorts indexes rather than large states, and no persistent cache is added"
|
||||
}
|
||||
}
|
||||
@@ -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,95 @@
|
||||
{
|
||||
"schemaVersion": "fidc-typed-current-rolling-helper/v1",
|
||||
"measuredAt": "2026-09-05T02:51:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "75ab0c06c66761d7b2b0edb3359359e13d1265e3",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "1071852a374620029398a967d485b827ed2defbb6ecbcc3f1ee6ffd3e2db76b6",
|
||||
"implementation": {
|
||||
"description": "compile static stock close/volume rolling_mean_current calls to typed DataSet kernels and bypass generic string normalization and helper dispatch",
|
||||
"evaluationRemainsLazy": true,
|
||||
"dynamicFieldsUseGenericFallback": true,
|
||||
"stockStateSizeChanged": false,
|
||||
"rollingFormulaChanged": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"startDate": "2021-08-23",
|
||||
"endDate": "2026-08-28",
|
||||
"frequency": "1d",
|
||||
"matchingType": "next_bar_open",
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"processCold": {
|
||||
"totalSeconds": 21.504,
|
||||
"dataSeconds": 12.753,
|
||||
"engineSeconds": 4.618,
|
||||
"unattributedSeconds": 3.489
|
||||
},
|
||||
"processHotEngineSeconds": [5.292, 4.729, 4.778, 4.635, 4.685],
|
||||
"processHotMedianEngineSeconds": 4.729,
|
||||
"calendarMajorBaselineMedianEngineSeconds": 5.202,
|
||||
"observedMedianImprovementPercent": 9.09265667051134,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [4.031, 4.08, 4.049, 3.976],
|
||||
"medianEngineSeconds": 4.049,
|
||||
"calendarMajorBaselineMedianEngineSeconds": 4.878,
|
||||
"observedMedianImprovementPercent": 16.99466994669946,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"hardwareCounters": {
|
||||
"candidateRunId": "btr_1788547776300_3162323_11",
|
||||
"candidateEngineSeconds": 4.782,
|
||||
"candidateCycles": 13368504015,
|
||||
"candidateInstructions": 26049740736,
|
||||
"candidateBranches": 4601262679,
|
||||
"candidateBranchMisses": 19416727,
|
||||
"fieldProjectionBaselineInstructions": 26663937176,
|
||||
"fieldProjectionBaselineBranches": 4751846020,
|
||||
"observedInstructionReductionPercent": 2.303472423993083,
|
||||
"observedBranchReductionPercent": 3.1689440349331863
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788547704257_3162323_10",
|
||||
"engineSeconds": 12.057,
|
||||
"hostLoadAverageAfterRun": 35.31,
|
||||
"performanceComparisonExcluded": true,
|
||||
"genericResolveCurrentRollingMeanPresentInTopProfile": false,
|
||||
"typedCurrentVolumeKernelPercent": 2.25,
|
||||
"note": "profile percentages remain useful for call-path attribution, but this run overlapped heavy external and managed factor CPU load and is excluded from wall-time comparison"
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11495362560,
|
||||
"serviceCgroupPeakBytes": 11497156608,
|
||||
"processRssKiB": 11213936,
|
||||
"processAnonymousKiB": 11197740
|
||||
},
|
||||
"testGate": {
|
||||
"coreUnitTotal": 421,
|
||||
"corePassed": 415,
|
||||
"ignoredManualBenchmarks": 6,
|
||||
"failed": 0,
|
||||
"integrationSuitesPassed": true
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/typed-current-rolling-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/typed-current-rolling-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/typed-current-rolling-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/typed-current-rolling-profile-20260905/perf-report.txt",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/typed-current-rolling-perf-stat-20260905/perf-stat.csv"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_generic_typed_lazy_helper",
|
||||
"reason": "two different five-year contracts preserve exact outputs, both stable medians improve, hardware work falls, and the implementation keeps lazy short-circuit evaluation without enlarging per-stock state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schemaVersion": "fidc-uncached-selection-state/v1",
|
||||
"measuredAt": "2026-09-05T05:07:00+08:00",
|
||||
"host": "192.168.31.177",
|
||||
"engineCommit": "29faf7932ed7838d0a2178a34b3fe6a259bd9052",
|
||||
"serviceCommit": "9fd5a9e6d5668af57f6942fc3c4127953545d9c6",
|
||||
"runnerBinarySha256": "c67dbb4a4432697853a9146790ae507f1bc774506104a9be814b3435665e6c36",
|
||||
"implementation": {
|
||||
"description": "construct transient market-cap ordered selection states by value and leave rejected candidates out of the per-day Arc HashMap cache",
|
||||
"selectedStateBehavior": "later business use rebuilds and caches the selected or held symbol through the unchanged state API",
|
||||
"genericRankingChanged": false,
|
||||
"selectionResultCached": false,
|
||||
"rollingValueCached": false,
|
||||
"stateFieldsChanged": false,
|
||||
"pitSemanticsChanged": false
|
||||
},
|
||||
"primaryFiveYearContract": {
|
||||
"totalReturn": 0.9219861819172002,
|
||||
"tradeCount": 26088,
|
||||
"canonicalSha256": "b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234",
|
||||
"resultStoreSha256": "92343fb369fea68b2544b654151c5c43a935940580b14afbe8eefb3844f72a54",
|
||||
"restartOrCold": {
|
||||
"totalSeconds": 16.142,
|
||||
"dataSeconds": 12.836,
|
||||
"engineSeconds": 2.665
|
||||
},
|
||||
"processHotEngineSeconds": [2.622, 2.683, 2.67, 2.619, 2.667],
|
||||
"processHotMedianEngineSeconds": 2.667,
|
||||
"previousAcceptedMedianEngineSeconds": 3.294,
|
||||
"observedMedianImprovementPercent": 19.034608,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"secondaryFiveYearContract": {
|
||||
"totalReturn": 1.1342962298106998,
|
||||
"tradeCount": 19404,
|
||||
"canonicalSha256": "0dbd3fad624097c673c4a5ec2f545f95e9c21d1d5337bb2b48ffeb22b16cb2b9",
|
||||
"resultStoreSha256": "416d2f87241fb4c6b917f6aeecb588f82f6e7d51a103f4c53a74d11971f16839",
|
||||
"engineSeconds": [2.318, 2.358, 2.35, 2.345, 2.367, 2.371],
|
||||
"medianEngineSeconds": 2.358,
|
||||
"previousAcceptedMedianEngineSeconds": 2.922,
|
||||
"observedMedianImprovementPercent": 19.301848,
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"fullMinuteContract": {
|
||||
"startDate": "2025-01-02",
|
||||
"endDate": "2025-11-17",
|
||||
"scheduleTime": "10:18",
|
||||
"matchingType": "minute_last",
|
||||
"slippageModel": "price_ratio",
|
||||
"slippageValue": 0.001,
|
||||
"totalReturn": 0.03473222656500008,
|
||||
"tradeCount": 156,
|
||||
"canonicalSha256": "457c086b1bca784fe83f447bb22451925e8f022456bb0fc7b54237a7a7886849",
|
||||
"resultStoreSha256": "04d804ee02ad7bc9b3649b4a2b4ddd902a4242ef38e00edeed8058860e9a6997",
|
||||
"totalSeconds": [0.592, 0.593],
|
||||
"engineSeconds": [0.162, 0.165],
|
||||
"resultConsistent": true,
|
||||
"terminalAuditStatus": "clean"
|
||||
},
|
||||
"profile": {
|
||||
"runId": "btr_1788555931028_369369_14",
|
||||
"engineSeconds": 2.672,
|
||||
"allThreadEventCycles": 62382573448,
|
||||
"previousAllThreadEventCycles": 77490041805,
|
||||
"observedCycleReductionPercent": 19.496013,
|
||||
"stockStateBuilderPercent": 12.33,
|
||||
"numericVmEvaluatePercent": 11.55,
|
||||
"adjustedCloseMovingAveragePercent": 5.32,
|
||||
"mallocPercent": 1.51,
|
||||
"previousMallocPercent": 2.82,
|
||||
"stockStateCacheClearInTopProfile": false,
|
||||
"stockStateDropGlueInTopProfile": false,
|
||||
"lostSamples": 0
|
||||
},
|
||||
"memory": {
|
||||
"serviceCgroupCurrentBytes": 11482611712,
|
||||
"serviceCgroupPeakBytes": 11484585984,
|
||||
"cacheMemoryAddedBytes": 0
|
||||
},
|
||||
"testGate": {
|
||||
"workspaceTotal": 544,
|
||||
"passed": 536,
|
||||
"ignoredManualBenchmarks": 8,
|
||||
"failed": 0,
|
||||
"strictClippyStatus": "baseline_blocked_by_137_preexisting_warnings"
|
||||
},
|
||||
"remoteArtifacts": [
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/uncached-selection-state-primary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/uncached-selection-state-secondary-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/uncached-selection-state-full-minute-20260905.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/uncached-selection-state-profile-20260905/run.json",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/uncached-selection-state-profile-20260905/perf.data",
|
||||
"/srv/fidc/canonical/run/fidc-private/evidence/uncached-selection-state-profile-20260905/perf-report.txt"
|
||||
],
|
||||
"acceptance": {
|
||||
"status": "accepted_transient_selection_state",
|
||||
"reason": "two independent five-year contracts and the full-minute contract preserve exact outputs, both daily strategies reduce stable engine medians by about nineteen percent, all-thread cycles and allocator share fall, and no cache memory is added"
|
||||
}
|
||||
}
|
||||
@@ -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 项跳过。
|
||||
- Runner:360 项通过、3 项跳过。
|
||||
- 交易工作区链接 e3f1028:510 项通过、8 项跳过。
|
||||
|
||||
首次回归曾发现 Rhai 快路径仍绕过保护,修复后重新完整测试,未将失败候选部署。
|
||||
|
||||
## 真实回放
|
||||
|
||||
使用已保存的原始 strategy spec、初始资金、日期、基准、频率及全部执行配置,
|
||||
通过独立 runner 真正重新执行。固定为服务实际使用的16个逻辑CPU、Rayon8线程、Tokio16线程。
|
||||
|
||||
- 五年日线:2021-08-23 至 2026-08-28,1,000万元,25,408笔成交。
|
||||
- 分钟样本:2025-01-02 至 2025-11-17,100万元,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 与对象分配优化仍待完成。
|
||||
@@ -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,75 @@
|
||||
# Fixed-Point and Minute-Stream Acceptance
|
||||
|
||||
Acceptance date: 2026-08-31
|
||||
Engine commit: `cd116bc3ae77cac0989eb80185bb04d7440b8834`
|
||||
|
||||
## Scope
|
||||
|
||||
This acceptance separates execution precision from minute-data throughput. It
|
||||
does not use a strategy-specific shortcut and does not change strategy,
|
||||
matching, risk, slippage, commission, tax, or future-data semantics.
|
||||
|
||||
## Fixed-Point Boundary
|
||||
|
||||
- Execution money is signed micro-yuan `i128`.
|
||||
- Stock gross amount, commission, stamp tax, transfer fee, cash, liabilities,
|
||||
external cash flow, account units, position lot cost and PnL are fixed-point.
|
||||
- Futures cash, margin, transaction cost, realized PnL and position PnL are
|
||||
fixed-point.
|
||||
- Market indicators and return statistics remain `f64`; conversion occurs only
|
||||
at the execution-money boundary.
|
||||
- One-micro-yuan budget overruns fail instead of being hidden by float epsilon.
|
||||
|
||||
Verification command:
|
||||
|
||||
```bash
|
||||
cargo test -p fidc-core fixed -- --nocapture
|
||||
```
|
||||
|
||||
Result: 8 passed, 0 failed.
|
||||
|
||||
## Minute Data Boundary
|
||||
|
||||
- `history_intraday_quotes_at` uses a sorted execution-date index and scans
|
||||
backward until the requested bar count is satisfied.
|
||||
- The active timestamp and `include_now` flag control visibility; later bars are
|
||||
never visible.
|
||||
- Daily minute execution consumes a borrowed k-way merge ordered by timestamp
|
||||
and symbol. It does not clone the complete selected quote day before engine
|
||||
dispatch.
|
||||
- Execution quotes are released by trading date after the day finishes.
|
||||
|
||||
Release benchmark command:
|
||||
|
||||
```bash
|
||||
cargo test -p fidc-core --test intraday_history_performance --release -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Observed on the local acceptance host:
|
||||
|
||||
| Case | Workload | Result |
|
||||
| --- | --- | --- |
|
||||
| Bounded history | 200 queries over 60,000 rows | 0.000227 seconds, checksum 351450348000 |
|
||||
| Full-day materialization | 5,000 iterations x 240 rows | 0.049361 seconds |
|
||||
| Borrowed quote stream | 5,000 iterations x 240 rows | 0.012232 seconds |
|
||||
|
||||
The materialized and streamed timestamp checksums were both
|
||||
`2108693484000000`. The observed component speedup was about 4.04x. These
|
||||
numbers are component evidence only and are not an end-to-end SLA.
|
||||
|
||||
## Regression
|
||||
|
||||
```bash
|
||||
cargo test -p fidc-core --all-targets
|
||||
```
|
||||
|
||||
Result: 528 passed, 0 failed, 1 ignored manual benchmark. This includes
|
||||
execution-day risk, next-open open-price limits, minute timestamp visibility,
|
||||
slippage, minimum commission, stamp tax, volume limits, corporate actions,
|
||||
external cash-flow NAV treatment and futures account precision.
|
||||
|
||||
## Deployment Gate
|
||||
|
||||
This documentation-only correction does not require a service restart. Any
|
||||
future Source Lake or engine deployment still requires the official managed
|
||||
entrypoint and must fail closed while FIDC-managed factor work is active.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Market Day View Component Benchmark
|
||||
|
||||
Date: 2026-08-31
|
||||
|
||||
## Scope
|
||||
|
||||
The platform-expression selection loop already iterates one factor slice for a
|
||||
single trading date. The previous implementation still resolved the same date
|
||||
in the market and candidate `BTreeMap` for every symbol. `DailySnapshotView`
|
||||
borrows the existing immutable market/factor/candidate slices and dense row
|
||||
position arrays once per date, then performs only `symbol_id -> row` lookups.
|
||||
|
||||
The view does not copy snapshots, cache strategy results, share account state,
|
||||
or change missing-row behavior. The optimization is independent of strategy
|
||||
text, thresholds, rolling windows, execution mode and portfolio size.
|
||||
|
||||
## Release Component A/B
|
||||
|
||||
Contract:
|
||||
|
||||
- 6,000 symbols;
|
||||
- 200 complete lookup rounds;
|
||||
- each lookup reads market close and candidate `allow_buy`;
|
||||
- baseline and view checksums must be exactly equal;
|
||||
- `cargo test --release`, system allocator, local macOS host.
|
||||
|
||||
| Round | Baseline seconds | Day view seconds |
|
||||
| ---: | ---: | ---: |
|
||||
| 1 | 0.009000 | 0.002939 |
|
||||
| 2 | 0.004370 | 0.001555 |
|
||||
| 3 | 0.004274 | 0.001578 |
|
||||
|
||||
Median component time changed from `0.004370s` to `0.001578s`, an observed
|
||||
reduction of about `63.9%` (`2.77x`). This is a component result only and is
|
||||
not a complete backtest SLA.
|
||||
|
||||
## Correctness Gates
|
||||
|
||||
- sparse market-only symbols remain absent from factor/candidate views;
|
||||
- dense and binary-search fallback lookup semantics remain unchanged;
|
||||
- full engine suite: 529 passed, 3 ignored manual benchmarks;
|
||||
- next-open execution-day risk, minute matching, fees, slippage, volume limits,
|
||||
corporate actions, delisting and futures tests all passed.
|
||||
|
||||
## Deployment Status
|
||||
|
||||
Not deployed. The 177 FIDC-managed Boris factor task is still active, so no
|
||||
Source Lake, backtest service or engine restart is allowed. After the task
|
||||
ends naturally, acceptance must use the same frozen bundle and compare daily
|
||||
selection, orders, fills, holdings, NAV, risk facts and canonical digest for
|
||||
multiple daily/minute and fixed/dynamic-universe strategies.
|
||||
@@ -0,0 +1,133 @@
|
||||
# FIDC 开源回测与交易引擎设计审查
|
||||
|
||||
## 审查范围
|
||||
|
||||
本次审查直接读取以下只读参考源码。源码位于
|
||||
`/Volumes/T7-Data/WorkSpace/reference-trading`;原计划使用的
|
||||
`/Volumes/SystemSSD` 在审查时未挂载,因此没有向本机系统盘写入参考仓库。
|
||||
|
||||
| 项目 | 审查提交 | 重点 |
|
||||
|---|---|---|
|
||||
| NautilusTrader | `ac22d5cf4a7e` | Rust 事件内核、统一回测/实盘组件、时间事件堆 |
|
||||
| QuantConnect LEAN | `23b735d99a35` | 订阅同步、TimeSlice、Universe 生命周期、惰性集合 |
|
||||
| Microsoft Qlib | `79633dd9506e` | 表达式缓存、日历切片、内存/磁盘分层 |
|
||||
| vectorbt | `34b6d5935e3e` | NumPy/Numba 密集数组仿真、紧凑状态数组 |
|
||||
| Zipline Reloaded | `943010b9da84` | Pipeline DAG、窗口预取、分段执行、复权读取 |
|
||||
| Backtrader | `b853d7c90b67` | preload/runonce 与逐 bar/live 模式分离 |
|
||||
|
||||
## 可借鉴设计
|
||||
|
||||
### 1. 时间轴和执行状态必须统一,但热路径不必经过通用消息总线
|
||||
|
||||
NautilusTrader 的 `BacktestEngine` 复用数据、执行、风险和缓存组件,并用带稳定
|
||||
序号的最小时间事件堆推进多个时钟。LEAN 用 `SubscriptionSynchronizer` 将不同订阅
|
||||
合并到同一个 frontier,再生成唯一 `TimeSlice`。两者共同证明:回测和实盘应共享
|
||||
订单、风控和时间语义,而不是共享一段策略特例代码。
|
||||
|
||||
FIDC 已经以 `decision_date`、`execution_date`、调度时间和撮合时间构成统一执行合同,
|
||||
并由同一交易核心服务于回测、模拟盘和实盘。日线全市场选股不应改为逐字段消息
|
||||
广播;这会给每个股票状态增加分配和动态分派。通用事件总线只保留在订单、成交、
|
||||
配置审计及外部集成边界。
|
||||
|
||||
### 2. 批量研究计算和事件撮合必须使用不同执行形态
|
||||
|
||||
vectorbt 把纯数值组合压入连续数组和编译循环;Backtrader 在历史批量模式使用
|
||||
`preload + runonce`,进入 live/replay 或受限内存模式后关闭该路径。Zipline 的
|
||||
Pipeline 则先生成 DAG 执行计划,按依赖顺序计算,并允许按日期 chunk 控制内存。
|
||||
|
||||
FIDC 应继续保持:
|
||||
|
||||
- 日线基础特征、rolling、横截面 rank 和因子挖掘使用 Arrow/NumPy/DuckDB/紧凑数组;
|
||||
- 订单、成交、T+1、涨跌停、停牌、公司行动和现金流使用确定性事件撮合;
|
||||
- 不把向量化收益外推到存在订单状态和路径依赖的撮合过程;
|
||||
- 不让逐 bar 实盘语义退化成预先知道整个未来数组的批量回测语义。
|
||||
|
||||
### 3. 不可变数据按内容身份共享,策略结果和可变账户状态严格隔离
|
||||
|
||||
Qlib 的缓存层、Zipline 的预取窗口和 Backtrader 的优化数据预载都说明:相同历史
|
||||
数据不应由每个策略重复解码。FIDC 当前 Source Lake 的 Parquet/Arrow、冻结 query
|
||||
scope、内容寻址 bundle、进程内 `DataSet` 和共享 result-store block 已符合这个方向。
|
||||
|
||||
共享键必须包含完整数据代际、字段投影、PIT 截止时间、复权口径和窗口。禁止共享:
|
||||
|
||||
- 选股结果、订单、仓位、账户、风控决策和策略局部变量;
|
||||
- 缺少 manifest/SHA/PIT 身份的 DataFrame 或 dict;
|
||||
- 盘中 provisional 数据与正式收盘数据混用的缓存项。
|
||||
|
||||
### 4. 字段和因子要在计划阶段冻结,运行时只物化真正需要的数据
|
||||
|
||||
LEAN 的 `TimeSliceFactory` 复用空集合,并只在收到对应数据时创建集合;Zipline 的
|
||||
Pipeline 使用执行计划和 refcount 释放中间值。FIDC 已有字段投影、
|
||||
`DailySnapshotView`、rolling requirement、Factor DAG 和 numeric bytecode VM。
|
||||
后续优化必须扩展这些类型化计划,而不是恢复宽 Python 行或每次构建完整 map。
|
||||
|
||||
## 当前性能事实
|
||||
|
||||
2026-09-06 在 177 使用同一冻结五年策略得到:
|
||||
|
||||
- restart/cold:`17.985s`;数据准备 `14.659s`;引擎 `2.627s`;
|
||||
- process-hot:总耗时中位 `3.245s`;数据准备 `0.006s`;引擎 `2.629s`;
|
||||
- 26,088 笔成交、收益 `0.9219861819172002`、canonical SHA
|
||||
`b42fea66237d06eadb24f6b8c9e2760e7319fe3699f315b99e01f433ef2aa234`
|
||||
在所有重复运行中一致;
|
||||
- 当前剖面热点为 numeric VM `12.39%`、临时股票状态构建 `10.17%`、复权均线
|
||||
`6.82%`;
|
||||
- Source Lake/进程共享缓存已经把同 bundle 数据准备从 `14.659s` 降到 `0.006s`,
|
||||
所以再把 DataFrame 改成 dict 或扩大 DuckDB 连接数不是当前热路径优化。
|
||||
|
||||
两项受开源类型化执行启发的候选已真实验证并删除:
|
||||
|
||||
1. 编译期枚举化全部 VM 标识符仅减少约 `1.44%` instructions,五年引擎中位只改善
|
||||
`0.19%`,却净增约 562 行;
|
||||
2. VM 槽位代际复用仅减少约 `0.48%` instructions,五年引擎中位回退约 `0.27%`。
|
||||
|
||||
完整证据见 `docs/evidence/numeric-vm-binding-generation-rejection-20260906.json`。
|
||||
|
||||
## FIDC 后续优化顺序
|
||||
|
||||
### P0:冷数据路径一次构建、分段发布、跨策略共享
|
||||
|
||||
以冻结 bundle 的完整身份构建 canonical base panel,并按年份或有界日期段发布
|
||||
只读 mmap/Arrow 段;父任务只扫描一次 Source Lake,worker 只映射所需段。每日增量
|
||||
只生成变化尾段,历史段保持同 SHA。必须以 process-cold `14.659s` 为基线,证明
|
||||
冷启动下降且 RSS、PIT、复权和结果 SHA 不变。
|
||||
|
||||
### P1:numeric VM 使用类型化 helper opcode 或经证据支持的 super-instruction
|
||||
|
||||
当前字符串标识符绑定不是主要成本。下一候选应在编译期把常见 rolling helper、
|
||||
比较和短路组合编译为类型化 opcode,减少解释器指令数,同时保留 helper 懒求值和
|
||||
错误信息。必须对主策略、第二种持仓数策略、通用自定义排序策略和分钟策略分别 A/B。
|
||||
|
||||
### P1:结果事件按消费者需求分层
|
||||
|
||||
撮合事实保持完整不可变;页面摘要、曲线、持仓和交易视图从 typed result store
|
||||
按需投影。禁止每次回测向 PostgreSQL 写入大矩阵,也禁止为了列表或概览解码全部
|
||||
事件。优化目标是 `resultSeconds + finalizationSeconds`,不能删减审计事实换速度。
|
||||
|
||||
### P2:分钟线有界流式窗口
|
||||
|
||||
参考 Zipline 的窗口预取和 Nautilus 的有序事件迭代器,按时间段加载分钟
|
||||
RecordBatch,保持持仓、订单和指标 ring buffer 有界;不能一次展开全市场全区间
|
||||
分钟 Python 对象,也不能在 chunk 边界丢失 corporate action、T+1 或订单队列状态。
|
||||
|
||||
## 明确不采用
|
||||
|
||||
- 不为单次策略把日循环拆成多线程;路径依赖会增加同步开销并破坏确定性。
|
||||
- 不通过增加 worker、DuckDB 槽位或扩大 HTTP 窗口掩盖单任务热点。
|
||||
- 不把全量历史数据复制成每个 worker 独占的 dict/DataFrame 缓存。
|
||||
- 不直接引入另一个框架的事件总线、账户或撮合实现;只借鉴机制并用中国市场合同验收。
|
||||
- 不缓存策略结果,也不以 request hash 返回旧结果代替真实回测。
|
||||
|
||||
## 验收矩阵
|
||||
|
||||
任何性能候选至少覆盖:
|
||||
|
||||
| 合同 | 必须保持 |
|
||||
|---|---|
|
||||
| 五年主策略 | 收益、成交数、canonical/result-store SHA、终态审计 |
|
||||
| 五年第二策略 | 不同持仓数下的同一组证据 |
|
||||
| 通用 rank 策略 | 非 market-cap 特例排序仍正确 |
|
||||
| 分钟策略 | 调度点、分钟成交价、滑点、成交量限制和 SHA |
|
||||
| 冷/热运行 | data/engine/result/finalization 分段、RSS、instructions/cycles |
|
||||
|
||||
任一合同漂移、只有微基准改善、或真实 wall/RSS 变差时,候选必须删除并保留拒绝证据。
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user