Compare commits
269 Commits
1a79dc483c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 29eafc79e2 | |||
| b6859a3360 | |||
| a00dea58b2 | |||
| 2445dc925a | |||
| 1e8d38f2ee | |||
| d2071e4311 | |||
| 0fba16342f | |||
| bd389de57f | |||
| bfbf898027 | |||
| 2b6d031a55 | |||
| 6160a74d2a | |||
| 4acecda79d | |||
| d847cb5c28 | |||
| fa0b316a8b | |||
| 21786187c9 | |||
| e0bed38184 | |||
| c0b78846d6 | |||
| 9d72567b99 | |||
| e47228beff | |||
| 1fc8a3a9e6 | |||
| 98199c02a2 | |||
| 6eaa06c1d6 | |||
| 7e0877b586 | |||
| 36833b7a6a | |||
| 4c0157b66c | |||
| f7d16fb664 | |||
| 97cdfa5972 | |||
| f2e228e0a3 | |||
| 33924b1fba | |||
| d3c36e9478 | |||
| c32807db1d | |||
| 20d723e2a1 | |||
| c2b939e818 | |||
| 6684f48f95 | |||
| adbfadcc07 | |||
| cb6f57be6f | |||
| e75db2d7b0 | |||
| e42dc6938b | |||
| 4f4c1ab7e0 | |||
| e549a23c66 | |||
| 4b21fc4f3f | |||
| e4bac1cf40 | |||
| 1de96494b3 | |||
| d9bac529d6 | |||
| 3e8d652af1 | |||
| 23043ee18b | |||
| c7c2e69b88 | |||
| e6746a7a0e | |||
| 123467d7ae | |||
| b3a3bdbdfd | |||
| 3f9cff1ee5 | |||
| db88abb9e0 | |||
| 75e5e32281 | |||
| 7d05f8f7c7 | |||
| d01f32ca5b | |||
| 3dd7b2bd50 | |||
| c8f6ed102c | |||
| 4664f1a2d3 | |||
| 40481e8825 | |||
| 2473cc04bb | |||
| 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 |
Generated
+1078
-8
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/fidc-core",
|
"crates/fidc-core",
|
||||||
|
"crates/fidc-signal-client",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ version = "0.1.0"
|
|||||||
authors = ["OpenAI Codex"]
|
authors = ["OpenAI Codex"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
sha2 = "=0.10.9"
|
||||||
ahash = "=0.8.12"
|
ahash = "=0.8.12"
|
||||||
chrono = { version = "=0.4.44", features = ["serde"] }
|
chrono = { version = "=0.4.44", features = ["serde"] }
|
||||||
indexmap = { version = "=2.11.4", features = ["serde"] }
|
indexmap = { version = "=2.11.4", features = ["serde"] }
|
||||||
@@ -18,5 +20,5 @@ reqwest = { version = "=0.12.24", default-features = false, features = ["json",
|
|||||||
rayon = "=1.12.0"
|
rayon = "=1.12.0"
|
||||||
rhai = { version = "=1.23.6", features = ["sync"] }
|
rhai = { version = "=1.23.6", features = ["sync"] }
|
||||||
serde = { version = "=1.0.228", features = ["derive"] }
|
serde = { version = "=1.0.228", features = ["derive"] }
|
||||||
serde_json = "=1.0.145"
|
serde_json = { version = "=1.0.145", features = ["float_roundtrip"] }
|
||||||
thiserror = "=2.0.18"
|
thiserror = "=2.0.18"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<p><img src="assets/brand/fiPandaWithTitle.png" alt="FiPanda" width="144" height="144"></p>
|
||||||
|
|
||||||
# fidc-backtest-engine
|
# fidc-backtest-engine
|
||||||
|
|
||||||
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
|
||||||
@@ -85,7 +87,7 @@ risk checks.
|
|||||||
- `selection.market_cap_band(...)` 动态市值带。
|
- `selection.market_cap_band(...)` 动态市值带。
|
||||||
- `filter.stock_expr(...)` 任意指标、因子和组合选股。
|
- `filter.stock_expr(...)` 任意指标、因子和组合选股。
|
||||||
- `ordering.rank_by(...)` 与 `ordering.rank_expr(...)` 排序。
|
- `ordering.rank_by(...)` 与 `ordering.rank_expr(...)` 排序。
|
||||||
- `allocation.buy_scale(...)` 动态买入资金比例。
|
- `allocation.buy_scale(...)` 相对等权槽位的个股资金倍率;显式权重可以大于 `1.0`,组合总仓位仍由 `risk.index_exposure(...)` 和严格资金预算控制。
|
||||||
- `risk.stop_loss(...)`、`risk.take_profit(...)` 多条件止盈止损。
|
- `risk.stop_loss(...)`、`risk.take_profit(...)` 多条件止盈止损。
|
||||||
- `order.*`、`cancel.*`、`update_universe(...)`、`subscribe(...)` 显式交易动作。
|
- `order.*`、`cancel.*`、`update_universe(...)`、`subscribe(...)` 显式交易动作。
|
||||||
|
|
||||||
@@ -99,6 +101,8 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与
|
|||||||
|
|
||||||
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
||||||
|
|
||||||
|
`targetPortfolioDaily=true` 时,每只股票的默认目标金额固定为 `target_budget / selection_limit * buy_scale`,候选不足、缺行情或风控拒绝产生的剩余资金保留为现金,不得自动归一到满仓。止盈、止损或最大持有期触发后,标的从活动目标顺序移除;卖出未完成时继续占用仓位槽且不得反向补买,成功释放的槽位只能由同一决策时点已排序且通过策略条件的后续候选补充。只有显式设置 `redistributeTargetWeightsAfterExit=true` 才在可用目标间重新分配权重,只有显式设置 `reenterExitedTargets=true` 才保留退出标的供后续重新入场;两个开关互相独立,默认都为 `false`。
|
||||||
|
|
||||||
## 内置微盘策略
|
## 内置微盘策略
|
||||||
|
|
||||||
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
|
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# FiPanda 品牌资源
|
||||||
|
|
||||||
|
FIDC 系列共用用户提供的两张原始透明图片,不重绘、不裁切、不染色。
|
||||||
|
|
||||||
|
- `fiPandaWithTitle.png`:带标题版,登录页、项目首页、独立报告使用。
|
||||||
|
- `fipanda.png`:无标题版,顶部导航、小尺寸标识和系统图标使用。
|
||||||
|
- `favicon-32.png`:由无标题版等比生成,供浏览器标签页使用。
|
||||||
|
|
||||||
|
导航图标建议 28–42px;带标题版建议 96–160px。始终等比完整显示,保留透明背景,不为 Logo 增加大块容器或改变业务页面主题。项目名称、权限、交易合同与内部 FIDC 标识不因品牌图片变更而重命名。
|
||||||
|
|
||||||
|
原图保存在 `omniquant/assets/brand/source/`;`manifest.json` 记录原图和显示资源的 SHA-256。需要更新时,在完整工作区运行 `node omniquant/scripts/sync-fipanda-branding.mjs`,统一生成八个仓库及可下载策略技能包的资源,禁止分别维护不同版本。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"schema": "fidc.fipanda-brand/v1",
|
||||||
|
"brand": "FiPanda",
|
||||||
|
"sourceRepository": "omniquant",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"file": "fipanda.png",
|
||||||
|
"sha256": "ff7dc0677b7d9c8dc6d008d3391f27fd583e3de35560dab021c1e2f8f2c464e5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "fiPandaWithTitle.png",
|
||||||
|
"sha256": "01bfb6acb36830d9b1ce6f7dc8c718977d43af44572ee6a26991d6c6aa2847d6"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"file": "fipanda.png",
|
||||||
|
"source": "fipanda.png",
|
||||||
|
"pixels": 192,
|
||||||
|
"usage": "navigation, compact icons, apple-touch-icon",
|
||||||
|
"sha256": "450aad3506ec3c67624b7705f4d8f851a9102fe950e7671a06fb1203dc1d9cf7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "fiPandaWithTitle.png",
|
||||||
|
"source": "fiPandaWithTitle.png",
|
||||||
|
"pixels": 384,
|
||||||
|
"usage": "login, project README, standalone reports",
|
||||||
|
"sha256": "d33846b3687ea69d43b4a1f17ceeb02662bd25f26fabc4c86c25b3475f20dede"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "favicon-32.png",
|
||||||
|
"source": "fipanda.png",
|
||||||
|
"pixels": 32,
|
||||||
|
"usage": "browser tab icon",
|
||||||
|
"sha256": "ec3d7783457db284d46ae6e27833685203838637a7d68946ff85402725f8e420"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -13,4 +13,6 @@ rayon.workspace = true
|
|||||||
rhai.workspace = true
|
rhai.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
ta-lib = { git = "https://github.com/TA-Lib/ta-lib.git", rev = "dd5a90259a3f9e04e2da9f38bf0719a841b40108" }
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use fidc_core::factor_events::{self, Expr, Frame};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::io::{self, Read};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Request {
|
||||||
|
expressions: std::collections::BTreeMap<String, Expr>,
|
||||||
|
frame: Frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_to_string(&mut input)?;
|
||||||
|
let output = if input.trim().is_empty() {
|
||||||
|
factor_events::catalog()
|
||||||
|
} else if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
|
||||||
|
let value:Value=serde_json::from_str(&input)?;
|
||||||
|
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
|
||||||
|
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
|
||||||
|
} else {
|
||||||
|
let request: Request = serde_json::from_str(&input)?;
|
||||||
|
let results = request
|
||||||
|
.expressions
|
||||||
|
.iter()
|
||||||
|
.map(|(id, expr)| {
|
||||||
|
let result = match factor_events::evaluate(expr, &request.frame) {
|
||||||
|
Ok(v) => json!({"result":v}),
|
||||||
|
Err(e) => json!({"error":e}),
|
||||||
|
};
|
||||||
|
(id.clone(), result)
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeMap<String, Value>>();
|
||||||
|
json!({"contract":factor_events::CONTRACT,"results":results,"read_only":true})
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&output)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use std::io::{self, Read};
|
||||||
|
fn main() {
|
||||||
|
let mut input=String::new();io::stdin().read_to_string(&mut input).unwrap();
|
||||||
|
let request=serde_json::from_str(&input).unwrap();
|
||||||
|
match fidc_core::market_event_context::aggregate(request) {
|
||||||
|
Ok(value)=>println!("{}",serde_json::to_string(&value).unwrap()),
|
||||||
|
Err(error)=>{eprintln!("{error}");std::process::exit(1);}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
fn main() {
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_to_string(&mut input).unwrap();
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&input).unwrap();
|
||||||
|
let spec: fidc_core::daily_patterns::PatternSpec =
|
||||||
|
serde_json::from_value(value["spec"].clone()).unwrap();
|
||||||
|
let bars: Vec<fidc_core::session_events::MinuteBar> =
|
||||||
|
serde_json::from_value(value["bars"].clone()).unwrap();
|
||||||
|
let result = fidc_core::session_events::evaluate(
|
||||||
|
&spec.validate().unwrap(),
|
||||||
|
value["symbol"].as_str().unwrap(),
|
||||||
|
&bars,
|
||||||
|
serde_json::from_value(value["decision_at"].clone()).unwrap(),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(row) => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"contract":fidc_core::session_events::CONTRACT,"row":row,"read_only":true,"source_evidence_verified":false})
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut raw=Vec::new();
|
||||||
|
std::io::stdin().take(64*1024*1024+1).read_to_end(&mut raw)?;
|
||||||
|
if raw.len()>64*1024*1024 {return Err("signal_book_transport_limit".into());}
|
||||||
|
let book:fidc_core::signal_contract::SignalBook=serde_json::from_slice(&raw)?;
|
||||||
|
let version=book.content_sha256()?;
|
||||||
|
let validated=book.validate()?;
|
||||||
|
let result=serde_json::json!({"schema":fidc_core::signal_contract::SIGNAL_BOOK_SCHEMA,
|
||||||
|
"versionSha256":version,"symbols":validated.symbols(),
|
||||||
|
"onlineAllowed":validated.require_observed().is_ok()});
|
||||||
|
std::io::stdout().write_all(serde_json::to_string(&result)?.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+1154
-219
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1425
-272
File diff suppressed because it is too large
Load Diff
+310
-103
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -90,6 +91,9 @@ impl Default for ProcessEventRetention {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DailyEquityPoint {
|
pub struct DailyEquityPoint {
|
||||||
|
/// Close-of-signal-day cash baseline before lagged trading begins.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub signal_baseline: bool,
|
||||||
#[serde(with = "date_format")]
|
#[serde(with = "date_format")]
|
||||||
pub date: NaiveDate,
|
pub date: NaiveDate,
|
||||||
pub cash: f64,
|
pub cash: f64,
|
||||||
@@ -108,6 +112,14 @@ pub struct DailyEquityPoint {
|
|||||||
pub diagnostics: String,
|
pub diagnostics: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DailyEquityPoint {
|
||||||
|
pub fn benchmark_reference_close(&self) -> f64 {
|
||||||
|
if self.signal_baseline { self.benchmark_close }
|
||||||
|
else if self.benchmark_prev_close.is_finite() && self.benchmark_prev_close > f64::EPSILON { self.benchmark_prev_close }
|
||||||
|
else { self.benchmark_close }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BacktestResult {
|
pub struct BacktestResult {
|
||||||
pub strategy_name: String,
|
pub strategy_name: String,
|
||||||
@@ -333,7 +345,7 @@ impl BacktestResult {
|
|||||||
let mut previous_benchmark = self
|
let mut previous_benchmark = self
|
||||||
.equity_curve
|
.equity_curve
|
||||||
.first()
|
.first()
|
||||||
.map(|point| point.benchmark_prev_close)
|
.map(DailyEquityPoint::benchmark_reference_close)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
for point in &self.equity_curve {
|
for point in &self.equity_curve {
|
||||||
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
||||||
@@ -453,11 +465,80 @@ pub struct BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel,
|
futures_cost_model: FuturesTransactionCostModel,
|
||||||
futures_validation_config: FuturesValidationConfig,
|
futures_validation_config: FuturesValidationConfig,
|
||||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||||
|
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||||
execution_quote_request_cache:
|
execution_quote_request_cache:
|
||||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||||
|
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
|
||||||
|
execution_lifecycle_reported: BTreeSet<(String, String)>,
|
||||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
|
||||||
|
let mut instruments = data.instruments().values()
|
||||||
|
.filter(|instrument| !data.is_reference_only_benchmark(&instrument.symbol)).peekable();
|
||||||
|
instruments.peek().is_some() && instruments.all(|instrument| instrument.dated_market_absence_reason(date).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backtest_execution_schedule(
|
||||||
|
data: &DataSet,
|
||||||
|
start_date: Option<NaiveDate>,
|
||||||
|
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))));
|
||||||
|
} else if all_instruments_have_dated_absence(data, execution_date) {
|
||||||
|
schedule.push((execution_date, None));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !has_execution_market(execution_date) {
|
||||||
|
if all_instruments_have_dated_absence(data, execution_date) {
|
||||||
|
schedule.push((execution_date, None));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let decision_slot = calendar_idx
|
||||||
|
.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)),
|
||||||
|
Some((_, decision_date)) if all_instruments_have_dated_absence(data, decision_date) => 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> {
|
impl<S, C, R> BacktestEngine<S, C, R> {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
data: DataSet,
|
data: DataSet,
|
||||||
@@ -485,7 +566,10 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel::default(),
|
futures_cost_model: FuturesTransactionCostModel::default(),
|
||||||
futures_validation_config: FuturesValidationConfig::default(),
|
futures_validation_config: FuturesValidationConfig::default(),
|
||||||
execution_quote_loader: None,
|
execution_quote_loader: None,
|
||||||
|
preplanned_decision_quote_symbols_by_date: None,
|
||||||
execution_quote_request_cache: BTreeSet::new(),
|
execution_quote_request_cache: BTreeSet::new(),
|
||||||
|
execution_absence_notes: BTreeMap::new(),
|
||||||
|
execution_lifecycle_reported: BTreeSet::new(),
|
||||||
risk_free_rate_contract: None,
|
risk_free_rate_contract: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,6 +593,14 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_preplanned_decision_quote_symbols_by_date(
|
||||||
|
mut self,
|
||||||
|
symbols_by_date: Arc<BTreeMap<NaiveDate, BTreeSet<String>>>,
|
||||||
|
) -> Self {
|
||||||
|
self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
||||||
self.dividend_reinvestment = enabled;
|
self.dividend_reinvestment = enabled;
|
||||||
self
|
self
|
||||||
@@ -692,6 +784,31 @@ where
|
|||||||
end_time: Option<NaiveTime>,
|
end_time: Option<NaiveTime>,
|
||||||
symbols: &mut BTreeSet<String>,
|
symbols: &mut BTreeSet<String>,
|
||||||
) -> Result<(), BacktestError> {
|
) -> Result<(), BacktestError> {
|
||||||
|
let mut available = BTreeSet::new();
|
||||||
|
for symbol in symbols.iter() {
|
||||||
|
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||||
|
"execution_data_missing reason=instrument_metadata_or_code_mapping_missing symbol={symbol} execution_date={execution_date}"
|
||||||
|
)))?;
|
||||||
|
if let Some(reason) = instrument.dated_market_absence_reason(execution_date) {
|
||||||
|
if self.data.price(execution_date, symbol, PriceField::Close).is_some()
|
||||||
|
|| !self.data.execution_quotes_on(execution_date, symbol).is_empty()
|
||||||
|
{
|
||||||
|
return Err(BacktestError::Execution(format!(
|
||||||
|
"execution_data_conflict reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?}",
|
||||||
|
instrument.listed_at, instrument.delisted_at
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.execution_lifecycle_reported.insert((symbol.clone(), reason.to_string())) {
|
||||||
|
self.execution_absence_notes.entry(execution_date).or_default().push(format!(
|
||||||
|
"execution_data_absence reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?} no_price_fill=true",
|
||||||
|
instrument.listed_at, instrument.delisted_at
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
available.insert(symbol.clone());
|
||||||
|
}
|
||||||
|
*symbols = available;
|
||||||
symbols.retain(|symbol| {
|
symbols.retain(|symbol| {
|
||||||
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
let request_key = (execution_date, symbol.clone(), start_time, end_time);
|
||||||
if self.execution_quote_request_cache.contains(&request_key) {
|
if self.execution_quote_request_cache.contains(&request_key) {
|
||||||
@@ -759,9 +876,6 @@ where
|
|||||||
let mut paused_with_quotes = Vec::new();
|
let mut paused_with_quotes = Vec::new();
|
||||||
let mut missing_daily_market = Vec::new();
|
let mut missing_daily_market = Vec::new();
|
||||||
for symbol in requested_symbols {
|
for symbol in requested_symbols {
|
||||||
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(market) = self.data.market(execution_date, symbol) else {
|
let Some(market) = self.data.market(execution_date, symbol) else {
|
||||||
missing_daily_market.push(symbol.clone());
|
missing_daily_market.push(symbol.clone());
|
||||||
continue;
|
continue;
|
||||||
@@ -1977,52 +2091,16 @@ where
|
|||||||
self.subscriptions = self.strategy.initial_subscriptions();
|
self.subscriptions = self.strategy.initial_subscriptions();
|
||||||
let scheduler_calendar = self.data.calendar().clone();
|
let scheduler_calendar = self.data.calendar().clone();
|
||||||
let scheduler = Scheduler::new(&scheduler_calendar);
|
let scheduler = Scheduler::new(&scheduler_calendar);
|
||||||
let calendar_dates = self
|
let execution_schedule = backtest_execution_schedule(
|
||||||
.data
|
&self.data,
|
||||||
.calendar()
|
self.config.start_date,
|
||||||
|
self.config.end_date,
|
||||||
|
self.config.decision_lag_trading_days,
|
||||||
|
);
|
||||||
|
let execution_dates = execution_schedule
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|date| {
|
.map(|(execution_date, _)| *execution_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))
|
|
||||||
.collect::<Vec<_>>();
|
.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 {
|
let mut result = BacktestResult {
|
||||||
strategy_name: self.strategy.name().to_string(),
|
strategy_name: self.strategy.name().to_string(),
|
||||||
benchmark_series: self
|
benchmark_series: self
|
||||||
@@ -2117,7 +2195,9 @@ where
|
|||||||
let day_order_start = result.order_events.len();
|
let day_order_start = result.order_events.len();
|
||||||
let day_fill_start = result.fills.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 Some((decision_index, decision_date)) = decision_slot else {
|
||||||
let mut process_events = Vec::new();
|
let mut process_events = Vec::new();
|
||||||
let mut report = BrokerExecutionReport::default();
|
let mut report = BrokerExecutionReport::default();
|
||||||
@@ -2149,12 +2229,13 @@ where
|
|||||||
date: execution_date,
|
date: execution_date,
|
||||||
})?;
|
})?;
|
||||||
let notes = join_text_parts(corporate_action_notes.into_iter());
|
let notes = join_text_parts(corporate_action_notes.into_iter());
|
||||||
|
let absence = all_instruments_have_dated_absence(&self.data, execution_date);
|
||||||
let diagnostics = join_text_parts(
|
let diagnostics = join_text_parts(
|
||||||
std::iter::once(format!(
|
std::iter::once(if absence {
|
||||||
"decision_lag_warmup lag_days={} execution_index={}",
|
format!("execution_data_absence reason=all_instruments_outside_dated_lifecycle execution_date={execution_date} cash_period_retained=true no_price_fill=true")
|
||||||
self.config.decision_lag_trading_days, execution_idx
|
} else { format!("decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx) })
|
||||||
))
|
.chain(broker_diagnostics.into_iter())
|
||||||
.chain(broker_diagnostics.into_iter()),
|
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||||
);
|
);
|
||||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||||
let holding_start = result.daily_holdings.len();
|
let holding_start = result.daily_holdings.len();
|
||||||
@@ -2171,6 +2252,7 @@ where
|
|||||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||||
|
|
||||||
result.equity_curve.push(DailyEquityPoint {
|
result.equity_curve.push(DailyEquityPoint {
|
||||||
|
signal_baseline: execution_idx == 0,
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
cash: aggregate_cash,
|
cash: aggregate_cash,
|
||||||
market_value: aggregate_market_value,
|
market_value: aggregate_market_value,
|
||||||
@@ -2233,8 +2315,7 @@ where
|
|||||||
let coarse_schedule_rules = schedule_rules
|
let coarse_schedule_rules = schedule_rules
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|rule| {
|
.filter(|rule| {
|
||||||
keep_timed_rules_on_coarse_stage
|
keep_timed_rules_on_coarse_stage || !is_on_day_or_bar_physical_time_rule(rule)
|
||||||
|| !is_on_day_or_bar_physical_time_rule(rule)
|
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -2500,32 +2581,47 @@ where
|
|||||||
)?;
|
)?;
|
||||||
let on_day_open_orders = self.open_order_views();
|
let on_day_open_orders = self.open_order_views();
|
||||||
let decision_quote_times = self.strategy.decision_quote_times();
|
let decision_quote_times = self.strategy.decision_quote_times();
|
||||||
if !decision_quote_times.is_empty() {
|
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
||||||
let decision_quote_symbols =
|
if let Some(preplanned) = self
|
||||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
.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,
|
execution_date,
|
||||||
decision_date,
|
decision_quote_symbols,
|
||||||
decision_index,
|
&decision_quote_times,
|
||||||
data: &self.data,
|
)?;
|
||||||
portfolio: &portfolio,
|
} else {
|
||||||
futures_account: self.futures_account.as_ref(),
|
let decision_quote_symbols =
|
||||||
open_orders: &on_day_open_orders,
|
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||||
dynamic_universe: self.dynamic_universe.as_ref(),
|
execution_date,
|
||||||
subscriptions: &self.subscriptions,
|
|
||||||
process_events: &process_events,
|
|
||||||
active_process_event: None,
|
|
||||||
active_datetime: stage_datetime(
|
|
||||||
decision_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::OnDay),
|
decision_index,
|
||||||
),
|
data: &self.data,
|
||||||
order_events: result.order_events.as_slice(),
|
portfolio: &portfolio,
|
||||||
fills: result.fills.as_slice(),
|
futures_account: self.futures_account.as_ref(),
|
||||||
})?;
|
open_orders: &on_day_open_orders,
|
||||||
self.ensure_execution_quotes_for_symbols_at_times(
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
||||||
execution_date,
|
subscriptions: &self.subscriptions,
|
||||||
&decision_quote_symbols,
|
process_events: &process_events,
|
||||||
&decision_quote_times,
|
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(
|
self.ensure_execution_quotes_for_portfolio_times(
|
||||||
execution_date,
|
execution_date,
|
||||||
@@ -2781,9 +2877,7 @@ where
|
|||||||
]);
|
]);
|
||||||
let minute_all_time_rules = intraday_schedule_rules
|
let minute_all_time_rules = intraday_schedule_rules
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|rule| {
|
.filter(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none())
|
||||||
rule.stage == ScheduleStage::Minute && rule.time_rule.is_none()
|
|
||||||
})
|
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let minute_schedule_all_times = !minute_all_time_rules.is_empty();
|
let minute_schedule_all_times = !minute_all_time_rules.is_empty();
|
||||||
@@ -2805,10 +2899,9 @@ where
|
|||||||
loop {
|
loop {
|
||||||
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
|
let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp);
|
||||||
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
|
let next_schedule_timestamp = minute_schedule_timestamps.peek().copied();
|
||||||
let Some(minute_timestamp) = next_minute_event_timestamp(
|
let Some(minute_timestamp) =
|
||||||
next_quote_timestamp,
|
next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp)
|
||||||
next_schedule_timestamp,
|
else {
|
||||||
) else {
|
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let minute_time = minute_timestamp.time();
|
let minute_time = minute_timestamp.time();
|
||||||
@@ -2823,8 +2916,7 @@ where
|
|||||||
.expect("peeked minute quote must be available"),
|
.expect("peeked minute quote must be available"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let has_specific_schedule =
|
let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp);
|
||||||
next_schedule_timestamp == Some(minute_timestamp);
|
|
||||||
if has_specific_schedule {
|
if has_specific_schedule {
|
||||||
minute_schedule_timestamps.next();
|
minute_schedule_timestamps.next();
|
||||||
}
|
}
|
||||||
@@ -2982,6 +3074,13 @@ where
|
|||||||
&mut minute_report.process_events,
|
&mut minute_report.process_events,
|
||||||
)?;
|
)?;
|
||||||
merge_broker_report(&mut report, minute_report);
|
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(
|
publish_phase_event(
|
||||||
&mut self.strategy,
|
&mut self.strategy,
|
||||||
&mut self.process_event_bus,
|
&mut self.process_event_bus,
|
||||||
@@ -3003,7 +3102,7 @@ where
|
|||||||
drop(minute_group);
|
drop(minute_group);
|
||||||
drop(minute_quotes);
|
drop(minute_quotes);
|
||||||
drop(quote_data);
|
drop(quote_data);
|
||||||
self.data.remove_execution_quotes_on_date(execution_date);
|
self.data.release_execution_quotes_on_date(execution_date);
|
||||||
}
|
}
|
||||||
|
|
||||||
portfolio.update_prices_with_options(
|
portfolio.update_prices_with_options(
|
||||||
@@ -3304,7 +3403,8 @@ where
|
|||||||
decision
|
decision
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(broker_diagnostics.into_iter()),
|
.chain(broker_diagnostics.into_iter())
|
||||||
|
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
|
||||||
);
|
);
|
||||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||||
let holding_start = result.daily_holdings.len();
|
let holding_start = result.daily_holdings.len();
|
||||||
@@ -3321,6 +3421,7 @@ where
|
|||||||
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
|
||||||
|
|
||||||
result.equity_curve.push(DailyEquityPoint {
|
result.equity_curve.push(DailyEquityPoint {
|
||||||
|
signal_baseline: false,
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
cash: aggregate_cash,
|
cash: aggregate_cash,
|
||||||
market_value: aggregate_market_value,
|
market_value: aggregate_market_value,
|
||||||
@@ -3903,17 +4004,11 @@ where
|
|||||||
let Some(instrument) = self.data.instrument(&symbol) else {
|
let Some(instrument) = self.data.instrument(&symbol) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let is_unresolved = instrument.is_delisted_on_or_before(date)
|
let is_unresolved = instrument.is_delisted_on_or_before(date);
|
||||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
|
||||||
&& instrument.delisted_at.is_none()
|
|
||||||
&& self.data.market(date, &symbol).is_none());
|
|
||||||
if !is_unresolved {
|
if !is_unresolved {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let effective_delisted_at = instrument
|
let effective_delisted_at = instrument.delisted_at.expect("dated delisting checked");
|
||||||
.delisted_at
|
|
||||||
.or_else(|| self.data.calendar().previous_day(date))
|
|
||||||
.unwrap_or(date);
|
|
||||||
let reason = format!(
|
let reason = format!(
|
||||||
concat!(
|
concat!(
|
||||||
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
|
||||||
@@ -4910,6 +5005,8 @@ mod tests {
|
|||||||
quantity: 100,
|
quantity: 100,
|
||||||
reason: rule.name.clone(),
|
reason: rule.name.clone(),
|
||||||
}],
|
}],
|
||||||
|
notes: vec![format!("note:{}", rule.name)],
|
||||||
|
diagnostics: vec![format!("diagnostic:{}", rule.name)],
|
||||||
..StrategyDecision::default()
|
..StrategyDecision::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -5302,6 +5399,7 @@ mod tests {
|
|||||||
pe_ttm: 12.0,
|
pe_ttm: 12.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5457,6 +5555,56 @@ mod tests {
|
|||||||
.expect("dataset")
|
.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]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wholly_prelisting_universe_retains_cash_days_without_fabricating_prices() {
|
||||||
|
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
|
||||||
|
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
||||||
|
engine.config.end_date = Some(dates[2]);
|
||||||
|
let mut markets = vec![market(dates[2], 10.0, 10.0)];
|
||||||
|
markets.extend(dates.iter().map(|date| DailyMarketSnapshot { symbol: "000852.SH".into(), ..market(*date, 1000.0, 1000.0) }));
|
||||||
|
engine.data = DataSet::from_components(
|
||||||
|
vec![Instrument { listed_at: Some(dates[2]), ..default_instrument() }, Instrument { symbol: "000852.SH".into(), listed_at: None, ..default_instrument() }],
|
||||||
|
markets, vec![factor(dates[2])], vec![candidate(dates[2])],
|
||||||
|
dates.iter().map(|date| benchmark(*date)).collect(),
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 0), dates);
|
||||||
|
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 1), dates);
|
||||||
|
let result = engine.run().unwrap();
|
||||||
|
assert_eq!(result.equity_curve.len(), 3);
|
||||||
|
for point in &result.equity_curve[..2] {
|
||||||
|
assert_eq!(point.total_equity, 100_000.0);
|
||||||
|
assert_eq!(point.market_value, 0.0);
|
||||||
|
assert!(point.diagnostics.contains("cash_period_retained=true"));
|
||||||
|
}
|
||||||
|
assert!(result.order_events.is_empty());
|
||||||
|
assert!(engine.data.market(dates[0], SYMBOL).is_none());
|
||||||
|
assert!(result.equity_curve[0].signal_baseline);
|
||||||
|
assert!(!result.equity_curve[1].signal_baseline);
|
||||||
|
assert!(!super::all_instruments_have_dated_absence(&dataset(), dates[0]));
|
||||||
|
}
|
||||||
|
|
||||||
fn engine_with_matching(
|
fn engine_with_matching(
|
||||||
matching_type: MatchingType,
|
matching_type: MatchingType,
|
||||||
execution_price_field: PriceField,
|
execution_price_field: PriceField,
|
||||||
@@ -5658,10 +5806,21 @@ mod tests {
|
|||||||
result.fills[0].execution_start_timestamp,
|
result.fills[0].execution_start_timestamp,
|
||||||
date.and_hms_opt(10, 18, 0)
|
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[1].price, 10.0);
|
||||||
assert_eq!(result.fills[0].reason, "morning");
|
assert_eq!(result.fills[0].reason, "morning");
|
||||||
assert_eq!(result.fills[1].reason, "post_close");
|
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]
|
#[test]
|
||||||
@@ -5899,6 +6058,38 @@ mod tests {
|
|||||||
.expect("zero-volume stock may have no minute bars");
|
.expect("zero-volume stock may have no minute bars");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_quote_filter_skips_only_dated_legal_absence_before_loading() {
|
||||||
|
let date = d(2025, 9, 10);
|
||||||
|
for (symbol, listed_at, delisted_at, reason) in [
|
||||||
|
("920038.BJ", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||||
|
("563360.SH", Some(d(2026, 8, 5)), None, "not_yet_listed"),
|
||||||
|
("000001.SZ", Some(d(2010, 1, 1)), Some(d(2025, 9, 9)), "delisted"),
|
||||||
|
] {
|
||||||
|
let instrument = Instrument { symbol: symbol.into(), listed_at, delisted_at, ..default_instrument() };
|
||||||
|
let data = DataSet::from_components(vec![instrument], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||||
|
let mut engine = full_day_coverage_engine(data, date);
|
||||||
|
engine.execution_quote_loader = Some(Box::new(|_| panic!("legal lifecycle absence must not load prices")));
|
||||||
|
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||||
|
let notes = engine.execution_absence_notes.get(&date).unwrap();
|
||||||
|
assert!(notes[0].contains(reason));
|
||||||
|
assert!(notes[0].contains(symbol));
|
||||||
|
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
|
||||||
|
assert_eq!(engine.execution_absence_notes[&date].len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_identity_or_missing_candidate_does_not_waive_quote_coverage() {
|
||||||
|
let date = d(2025, 9, 10);
|
||||||
|
let data = DataSet::from_components(vec![default_instrument()], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
|
||||||
|
let mut engine = full_day_coverage_engine(data, date);
|
||||||
|
let error = engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from(["unmapped".to_string()])).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("instrument_metadata_or_code_mapping_missing"));
|
||||||
|
let error = engine.validate_full_day_execution_quote_coverage(date, &[SYMBOL.to_string()]).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("missing_daily_market"));
|
||||||
|
}
|
||||||
|
|
||||||
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
||||||
run_scheduled_next_open_with_dataset_and_broker(
|
run_scheduled_next_open_with_dataset_and_broker(
|
||||||
dataset,
|
dataset,
|
||||||
@@ -6508,17 +6699,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() {
|
fn next_bar_open_execution_risk_rejects_one_yuan_open_despite_higher_close() {
|
||||||
let first = d(2025, 1, 2);
|
let first = d(2025, 1, 2);
|
||||||
let second = d(2025, 1, 3);
|
let second = d(2025, 1, 3);
|
||||||
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||||
market(first, 10.0, 11.5),
|
market(first, 10.0, 11.5),
|
||||||
market(second, 12.0, 99.0),
|
market(second, 0.9, 1.2),
|
||||||
|
candidate(first),
|
||||||
|
candidate(second),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_next_open_canceled_with_reason(&result, "one_yuan");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_bar_open_execution_risk_ignores_later_one_yuan_close() {
|
||||||
|
let first = d(2025, 1, 2);
|
||||||
|
let second = d(2025, 1, 3);
|
||||||
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||||
|
market(first, 10.0, 11.5),
|
||||||
|
market(second, 1.2, 0.9),
|
||||||
candidate(first),
|
candidate(first),
|
||||||
one_yuan_candidate(second),
|
one_yuan_candidate(second),
|
||||||
));
|
));
|
||||||
|
|
||||||
assert_next_open_canceled_with_reason(&result, "one_yuan");
|
assert_eq!(result.fills.len(), 1);
|
||||||
|
assert_eq!(result.fills[0].date, second);
|
||||||
|
assert_eq!(result.fills[0].price, 1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VolumeCapacityMode {
|
||||||
|
#[default]
|
||||||
|
ExecutionObservation,
|
||||||
|
CompletedBar,
|
||||||
|
SessionCapacityAudit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
|
pub enum CapacityError {
|
||||||
|
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
||||||
|
InvalidRatio,
|
||||||
|
#[error("execution capacity decimal cannot be represented exactly")]
|
||||||
|
InvalidDecimal,
|
||||||
|
#[error("execution capacity observation has invalid time bounds")]
|
||||||
|
InvalidWindow,
|
||||||
|
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
|
||||||
|
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
|
||||||
|
#[error("execution capacity observation belongs to another session")]
|
||||||
|
WrongSession,
|
||||||
|
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
||||||
|
MissingObservation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ParticipationRate {
|
||||||
|
numerator: u128,
|
||||||
|
denominator: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParticipationRate {
|
||||||
|
pub fn new(rate: f64) -> Result<Self, CapacityError> {
|
||||||
|
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
|
||||||
|
return Err(CapacityError::InvalidRatio);
|
||||||
|
}
|
||||||
|
if rate < 1e-20 {
|
||||||
|
// Even u64::MAX shares at this rate cannot admit a single share.
|
||||||
|
return Ok(Self { numerator: 0, denominator: 1 });
|
||||||
|
}
|
||||||
|
if rate == 1.0 {
|
||||||
|
return Ok(Self { numerator: 1, denominator: 1 });
|
||||||
|
}
|
||||||
|
let text = rate.to_string();
|
||||||
|
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
|
||||||
|
let digits = digits.trim_end_matches('0');
|
||||||
|
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
|
||||||
|
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
|
||||||
|
if numerator > u128::MAX / u128::from(u64::MAX) {
|
||||||
|
return Err(CapacityError::InvalidDecimal);
|
||||||
|
}
|
||||||
|
Ok(Self { numerator, denominator })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_shares(self, market_shares: u64) -> u64 {
|
||||||
|
let total = u128::from(market_shares) * self.numerator / self.denominator;
|
||||||
|
u64::try_from(total).expect("participation rate cannot exceed the market shares")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
|
||||||
|
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VolumeObservationKind {
|
||||||
|
TradeIncrement,
|
||||||
|
CompletedBar,
|
||||||
|
CumulativeSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeObservation {
|
||||||
|
pub kind: VolumeObservationKind,
|
||||||
|
pub start: NaiveDateTime,
|
||||||
|
pub end: NaiveDateTime,
|
||||||
|
pub available_at: NaiveDateTime,
|
||||||
|
pub shares: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeObservation {
|
||||||
|
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
|
||||||
|
if self.start > self.end || self.available_at < self.end {
|
||||||
|
return Err(CapacityError::InvalidWindow);
|
||||||
|
}
|
||||||
|
if self.available_at > execution_at {
|
||||||
|
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
|
||||||
|
}
|
||||||
|
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
|
||||||
|
return Err(CapacityError::WrongSession);
|
||||||
|
}
|
||||||
|
Ok(self.shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
|
||||||
|
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SessionCapacityAudit {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub symbol: String,
|
||||||
|
pub filled_shares: u64,
|
||||||
|
pub session_shares: u64,
|
||||||
|
pub allowed_shares: u64,
|
||||||
|
pub passed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionCapacityAudit {
|
||||||
|
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
|
||||||
|
let allowed_shares = rate.total_shares(session_shares);
|
||||||
|
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
|
||||||
|
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
|
||||||
|
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
|
||||||
|
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
|
||||||
|
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
|
||||||
|
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
|
||||||
|
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
|
||||||
|
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
|
||||||
|
assert!(ParticipationRate::new(rate).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_volume_cannot_be_used_for_an_earlier_open() {
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||||
|
let opening = day.and_hms_opt(9,30,0).unwrap();
|
||||||
|
let closing = day.and_hms_opt(15,0,0).unwrap();
|
||||||
|
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
|
||||||
|
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
|
||||||
|
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
|
||||||
|
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
|
||||||
|
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
|
||||||
|
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
|
||||||
|
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
|
||||||
|
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
|
||||||
|
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_audit_changes_verdict_not_executed_quantity() {
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
||||||
|
let rate = ParticipationRate::new(0.25).unwrap();
|
||||||
|
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
|
||||||
|
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
|
||||||
|
assert!(!a.passed); assert!(b.passed);
|
||||||
|
assert_eq!(a.filled_shares,b.filled_shares);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use crate::engine::BacktestError;
|
||||||
|
|
||||||
|
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
|
||||||
|
pub(crate) struct TwapSchedule {
|
||||||
|
start: NaiveDateTime,
|
||||||
|
end: NaiveDateTime,
|
||||||
|
quantity: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TwapSchedule {
|
||||||
|
pub(crate) fn new(
|
||||||
|
start: Option<NaiveDateTime>,
|
||||||
|
end: Option<NaiveDateTime>,
|
||||||
|
quantity: u32,
|
||||||
|
) -> Result<Self, BacktestError> {
|
||||||
|
let (Some(start), Some(end)) = (start, end) else {
|
||||||
|
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
|
||||||
|
};
|
||||||
|
if end < start || start.date() != end.date() {
|
||||||
|
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
|
||||||
|
}
|
||||||
|
Ok(Self { start, end, quantity })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
|
||||||
|
if at < self.start {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let entitlement = if at >= self.end {
|
||||||
|
self.quantity
|
||||||
|
} else {
|
||||||
|
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||||
|
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
|
||||||
|
(u128::from(self.quantity) * elapsed / duration) as u32
|
||||||
|
};
|
||||||
|
entitlement.saturating_sub(filled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{Duration, NaiveDate};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
|
||||||
|
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
|
||||||
|
let end = start + Duration::minutes(10);
|
||||||
|
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
|
||||||
|
assert_eq!(schedule.due_quantity(start, 0), 0);
|
||||||
|
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
|
||||||
|
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
|
||||||
|
assert_eq!(schedule.due_quantity(end, 100), 900);
|
||||||
|
assert_eq!(schedule.due_quantity(end, 1_000), 0);
|
||||||
|
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
|
||||||
|
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
|
||||||
|
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! Cross-sectional operators require an explicit complete universe, never a UI page.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
pub const OPERATORS: &[&str] = &[
|
||||||
|
"RANK",
|
||||||
|
"PERCENTILE",
|
||||||
|
"TOP",
|
||||||
|
"BOTTOM",
|
||||||
|
"TOP_PERCENT",
|
||||||
|
"BOTTOM_PERCENT",
|
||||||
|
"WINSORIZE",
|
||||||
|
"INDUSTRY_NEUTRALIZE",
|
||||||
|
"SIZE_NEUTRALIZE",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Observation {
|
||||||
|
pub symbol: String,
|
||||||
|
pub value: f64,
|
||||||
|
pub industry: Option<String>,
|
||||||
|
pub market_cap: Option<f64>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Output {
|
||||||
|
pub symbol: String,
|
||||||
|
pub value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every date ranks the same frozen research universe; unknown inputs invalidate the whole date.
|
||||||
|
pub fn rank_history(
|
||||||
|
dates: &[chrono::NaiveDate], universe: &[String], values: &BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
use serde_json::json;
|
||||||
|
if dates.is_empty() || dates.windows(2).any(|w| w[0] >= w[1]) || universe.len() < 2
|
||||||
|
|| universe.len() > 20_000 || dates.len().saturating_mul(universe.len()) > 2_000_000
|
||||||
|
|| universe.iter().collect::<BTreeSet<_>>().len() != universe.len()
|
||||||
|
|| values.keys().collect::<BTreeSet<_>>() != universe.iter().collect::<BTreeSet<_>>()
|
||||||
|
|| values.values().any(|v| v.len() != dates.len() || v.iter().flatten().any(|v| !v.is_finite())) {
|
||||||
|
return Err("research_rank_history_incomplete_or_invalid_universe".into());
|
||||||
|
}
|
||||||
|
let mut rank = universe.iter().map(|s|(s.clone(),vec![None;dates.len()])).collect::<BTreeMap<_,_>>();
|
||||||
|
let mut percentile = rank.clone();
|
||||||
|
let mut unknown_dates = Vec::new();
|
||||||
|
for (i, date) in dates.iter().enumerate() {
|
||||||
|
let missing = universe.iter().filter(|s|values[*s][i].is_none()).collect::<Vec<_>>();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
unknown_dates.push(json!({"date":date,"missing_count":missing.len(),"missing_symbol_sample":missing.iter().take(20).collect::<Vec<_>>(),"sample_limit":20}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let observations = universe.iter().map(|s|Observation{symbol:s.clone(),value:values[s][i].unwrap(),industry:None,market_cap:None}).collect::<Vec<_>>();
|
||||||
|
for item in evaluate("RANK", universe, &observations, 0.0)? {rank.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||||
|
for item in evaluate("PERCENTILE", universe, &observations, 0.0)? {percentile.get_mut(&item.symbol).unwrap()[i]=Some(item.value);}
|
||||||
|
}
|
||||||
|
Ok(json!({"rank":rank,"percentile":percentile,"unknown_dates":unknown_dates,
|
||||||
|
"universe":universe,"dates":dates,"tie_policy":"average_rank_descending",
|
||||||
|
"membership_policy":"fixed_research_scope_not_historical_index_membership"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mean(values: &[f64]) -> f64 {
|
||||||
|
let base = values[0];
|
||||||
|
base + values
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.map(|v| (v - base) / values.len() as f64)
|
||||||
|
.sum::<f64>()
|
||||||
|
}
|
||||||
|
fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||||||
|
let x = p * (sorted.len() - 1) as f64;
|
||||||
|
let l = x.floor() as usize;
|
||||||
|
let r = x.ceil() as usize;
|
||||||
|
sorted[l] + (sorted[r] - sorted[l]) * (x - l as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(
|
||||||
|
name: &str,
|
||||||
|
universe: &[String],
|
||||||
|
rows: &[Observation],
|
||||||
|
threshold: f64,
|
||||||
|
) -> Result<Vec<Output>, String> {
|
||||||
|
let expected = universe.iter().collect::<BTreeSet<_>>();
|
||||||
|
if rows.is_empty()
|
||||||
|
|| rows.len() > 20_000
|
||||||
|
|| expected.len() != universe.len()
|
||||||
|
|| rows.len() != universe.len()
|
||||||
|
|| rows.iter().map(|r| &r.symbol).collect::<BTreeSet<_>>() != expected
|
||||||
|
|| rows.iter().any(|r| !r.value.is_finite())
|
||||||
|
{
|
||||||
|
return Err("cross_section_incomplete_or_invalid_universe".into());
|
||||||
|
}
|
||||||
|
if !OPERATORS.contains(&name) || !threshold.is_finite() {
|
||||||
|
return Err("cross_section_operator_invalid".into());
|
||||||
|
}
|
||||||
|
if matches!(name, "TOP" | "BOTTOM") && (threshold < 1.0 || threshold.fract() != 0.0)
|
||||||
|
|| matches!(name, "TOP_PERCENT" | "BOTTOM_PERCENT") && !(0.0..=1.0).contains(&threshold)
|
||||||
|
|| name == "WINSORIZE" && !(0.0..0.5).contains(&threshold)
|
||||||
|
{
|
||||||
|
return Err("cross_section_threshold_invalid".into());
|
||||||
|
}
|
||||||
|
let mut sorted = rows.iter().map(|r| r.value).collect::<Vec<_>>();
|
||||||
|
sorted.sort_by(f64::total_cmp);
|
||||||
|
let mut industry_values: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
|
||||||
|
if name == "INDUSTRY_NEUTRALIZE" {
|
||||||
|
for row in rows {
|
||||||
|
let industry = row
|
||||||
|
.industry
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.ok_or("cross_section_pit_industry_missing")?;
|
||||||
|
industry_values.entry(industry).or_default().push(row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = if name == "SIZE_NEUTRALIZE" {
|
||||||
|
let x = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
r.market_cap
|
||||||
|
.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
|
.map(f64::ln)
|
||||||
|
.ok_or("cross_section_market_cap_missing")
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let xm = mean(&x);
|
||||||
|
let ym = mean(&sorted);
|
||||||
|
let variance = x.iter().map(|v| (v - xm).powi(2)).sum::<f64>();
|
||||||
|
if variance == 0.0 || rows.len() < 3 {
|
||||||
|
return Err("cross_section_size_regression_unidentified".into());
|
||||||
|
}
|
||||||
|
let beta = x
|
||||||
|
.iter()
|
||||||
|
.zip(rows)
|
||||||
|
.map(|(x, y)| (x - xm) * (y.value - ym))
|
||||||
|
.sum::<f64>()
|
||||||
|
/ variance;
|
||||||
|
Some((x, xm, ym, beta))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
rows.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, row)| {
|
||||||
|
let low = sorted.partition_point(|v| *v < row.value);
|
||||||
|
let high = sorted.partition_point(|v| *v <= row.value);
|
||||||
|
let rank = (low + 1 + high) as f64 / 2.0;
|
||||||
|
let descending = (rows.len() + 1) as f64 - rank;
|
||||||
|
let percentile = if rows.len() == 1 {
|
||||||
|
0.5
|
||||||
|
} else {
|
||||||
|
(rank - 1.0) / (rows.len() - 1) as f64
|
||||||
|
};
|
||||||
|
let value = match name {
|
||||||
|
"RANK" => descending,
|
||||||
|
"PERCENTILE" => percentile,
|
||||||
|
"TOP" => f64::from(descending <= threshold),
|
||||||
|
"BOTTOM" => f64::from(rank <= threshold),
|
||||||
|
"TOP_PERCENT" => f64::from(descending <= threshold * rows.len() as f64),
|
||||||
|
"BOTTOM_PERCENT" => f64::from(rank <= threshold * rows.len() as f64),
|
||||||
|
"WINSORIZE" => row.value.clamp(
|
||||||
|
quantile(&sorted, threshold),
|
||||||
|
quantile(&sorted, 1.0 - threshold),
|
||||||
|
),
|
||||||
|
"INDUSTRY_NEUTRALIZE" => {
|
||||||
|
row.value - mean(&industry_values[row.industry.as_deref().unwrap()])
|
||||||
|
}
|
||||||
|
"SIZE_NEUTRALIZE" => {
|
||||||
|
let (x, xm, ym, beta) = size.as_ref().unwrap();
|
||||||
|
row.value - (ym + beta * (x[index] - xm))
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err("cross_section_result_nonfinite".into());
|
||||||
|
}
|
||||||
|
Ok(Output {
|
||||||
|
symbol: row.symbol.clone(),
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn historical_ranks_keep_ties_and_unknown_full_cross_sections() {
|
||||||
|
let dates=["2026-09-07","2026-09-08","2026-09-09"].map(|d|d.parse().unwrap());
|
||||||
|
let universe=vec!["A".into(),"B".into(),"C".into()];
|
||||||
|
let values=BTreeMap::from([("A".into(),vec![None,Some(10.0),Some(20.0)]),("B".into(),vec![Some(10.0),Some(10.0),Some(10.0)]),("C".into(),vec![Some(20.0),Some(5.0),Some(15.0)])]);
|
||||||
|
let out=rank_history(&dates,&universe,&values).unwrap();
|
||||||
|
assert_eq!(out["rank"]["A"],serde_json::json!([null,1.5,1.0]));
|
||||||
|
assert_eq!(out["rank"]["C"],serde_json::json!([null,3.0,2.0]));
|
||||||
|
assert_eq!(out["unknown_dates"][0]["missing_count"],1);
|
||||||
|
let earlier=values.iter().map(|(s,v)|(s.clone(),v[..2].to_vec())).collect();
|
||||||
|
let first=rank_history(&dates[..2],&universe,&earlier).unwrap();
|
||||||
|
assert_eq!(&out["rank"]["A"].as_array().unwrap()[..2],first["rank"]["A"].as_array().unwrap());
|
||||||
|
assert!(rank_history(&dates,&universe[..2],&values).is_err());
|
||||||
|
}
|
||||||
|
fn rows() -> Vec<Observation> {
|
||||||
|
[1.0, 3.0, 3.0, 4.0]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &value)| Observation {
|
||||||
|
symbol: format!("S{i}"),
|
||||||
|
value,
|
||||||
|
industry: Some(if i < 2 { "A" } else { "B" }.into()),
|
||||||
|
market_cap: Some(10.0 + i as f64),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ties_keep_equal_rank_and_missing_universe_rejects() {
|
||||||
|
let r = rows();
|
||||||
|
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||||
|
let out = evaluate("RANK", &u, &r, 0.0).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||||
|
vec![4.0, 2.5, 2.5, 1.0]
|
||||||
|
);
|
||||||
|
assert!(evaluate("RANK", &u, &r[..3], 0.0).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn neutralization_preserves_input_order() {
|
||||||
|
let r = rows();
|
||||||
|
let u = r.iter().map(|r| r.symbol.clone()).collect::<Vec<_>>();
|
||||||
|
let out = evaluate("INDUSTRY_NEUTRALIZE", &u, &r, 0.0).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
out.iter().map(|r| r.value).collect::<Vec<_>>(),
|
||||||
|
vec![-1.0, 1.0, -0.5, 0.5]
|
||||||
|
);
|
||||||
|
assert!(evaluate("TOP_PERCENT", &u, &r, 20.0).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! Indicator metadata is versioned independently from the numerical kernel.
|
||||||
|
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use ta_lib::abstract_api::{self, OptInputType};
|
||||||
|
|
||||||
|
pub fn catalog() -> Value {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut implementation = Sha256::new();
|
||||||
|
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||||
|
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||||
|
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||||
|
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||||
|
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||||
|
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||||
|
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||||
|
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
|
||||||
|
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||||
|
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||||
|
})).collect();
|
||||||
|
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||||
|
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||||
|
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||||
|
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||||
|
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||||
|
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||||
|
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||||
|
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||||
|
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||||
|
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||||
|
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
||||||
|
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
|
||||||
|
match kind {
|
||||||
|
OptInputType::IntegerRange { min, max, default, .. } => json!({
|
||||||
|
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
|
||||||
|
}),
|
||||||
|
OptInputType::RealRange { min, max, default, precision, .. } => json!({
|
||||||
|
"value_type":"number", "minimum":min, "maximum":max,
|
||||||
|
"default":default, "display_precision":precision,
|
||||||
|
}),
|
||||||
|
OptInputType::IntegerList { values, default } => json!({
|
||||||
|
"value_type":"integer", "default":default,
|
||||||
|
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
OptInputType::RealList { values, default } => json!({
|
||||||
|
"value_type":"number", "default":default,
|
||||||
|
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,936 @@
|
|||||||
|
//! Causal, typed indicator/event expressions shared by research and trading.
|
||||||
|
use chrono::{DateTime, FixedOffset};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use ta_lib::{
|
||||||
|
Core,
|
||||||
|
abstract_api::{self, InputType, OptInputType, OutputType},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CONTRACT: &str = "fidc_factor_event_expression_v1";
|
||||||
|
pub const TA_REV: &str = "dd5a90259a3f9e04e2da9f38bf0719a841b40108";
|
||||||
|
|
||||||
|
pub fn field_dependencies(expr: &Expr) -> std::collections::BTreeSet<String> {
|
||||||
|
let mut fields = std::collections::BTreeSet::new();
|
||||||
|
match expr {
|
||||||
|
Expr::Field { name } => {
|
||||||
|
fields.insert(name.clone());
|
||||||
|
}
|
||||||
|
Expr::Indicator { inputs, .. } => {
|
||||||
|
for e in inputs {
|
||||||
|
fields.extend(field_dependencies(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Operator { args, .. } => {
|
||||||
|
for e in args {
|
||||||
|
fields.extend(field_dependencies(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum Expr {
|
||||||
|
Number {
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
|
Field {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
Indicator {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
inputs: Vec<Expr>,
|
||||||
|
#[serde(default)]
|
||||||
|
parameters: BTreeMap<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
output: usize,
|
||||||
|
},
|
||||||
|
Operator {
|
||||||
|
name: String,
|
||||||
|
args: Vec<Expr>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
window: Option<usize>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Frame {
|
||||||
|
pub symbol: String,
|
||||||
|
pub frequency: String,
|
||||||
|
pub decision_at: DateTime<FixedOffset>,
|
||||||
|
pub timestamps: Vec<DateTime<FixedOffset>>,
|
||||||
|
pub available_at: Vec<DateTime<FixedOffset>>,
|
||||||
|
pub fields: BTreeMap<String, Vec<Option<f64>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ValueType {
|
||||||
|
Number,
|
||||||
|
Boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Series {
|
||||||
|
pub value_type: ValueType,
|
||||||
|
pub values: Vec<Option<f64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const OPERATORS: &[&str] = &[
|
||||||
|
"GT",
|
||||||
|
"GTE",
|
||||||
|
"LT",
|
||||||
|
"LTE",
|
||||||
|
"EQ",
|
||||||
|
"NEQ",
|
||||||
|
"BETWEEN",
|
||||||
|
"OUTSIDE",
|
||||||
|
"CROSS_ABOVE",
|
||||||
|
"CROSS_BELOW",
|
||||||
|
"BREAK_ABOVE",
|
||||||
|
"BREAK_BELOW",
|
||||||
|
"BREAK_HIGH",
|
||||||
|
"BREAK_LOW",
|
||||||
|
"CHANGE",
|
||||||
|
"DIFF",
|
||||||
|
"DELTA",
|
||||||
|
"PCT_CHANGE",
|
||||||
|
"LOG_RETURN",
|
||||||
|
"RISING",
|
||||||
|
"FALLING",
|
||||||
|
"NON_DECREASING",
|
||||||
|
"NON_INCREASING",
|
||||||
|
"TURN_UP",
|
||||||
|
"TURN_DOWN",
|
||||||
|
"BOTTOM_REVERSAL",
|
||||||
|
"TOP_REVERSAL",
|
||||||
|
"SLOPE",
|
||||||
|
"SLOPE_CHANGE",
|
||||||
|
"ACCELERATION",
|
||||||
|
"HHV",
|
||||||
|
"LLV",
|
||||||
|
"ARGMAX",
|
||||||
|
"ARGMIN",
|
||||||
|
"DISTANCE_TO_HIGH",
|
||||||
|
"DISTANCE_TO_LOW",
|
||||||
|
"NEW_HIGH",
|
||||||
|
"NEW_LOW",
|
||||||
|
"NEAR_HIGH",
|
||||||
|
"NEAR_LOW",
|
||||||
|
"BULLISH_DIVERGENCE",
|
||||||
|
"BEARISH_DIVERGENCE",
|
||||||
|
"ZSCORE",
|
||||||
|
"MINMAX",
|
||||||
|
"STANDARDIZE",
|
||||||
|
"NORMALIZE",
|
||||||
|
"COUNT",
|
||||||
|
"COUNT_TRUE",
|
||||||
|
"CONSECUTIVE",
|
||||||
|
"BARS_SINCE",
|
||||||
|
"DURATION",
|
||||||
|
"DAYS_SINCE",
|
||||||
|
"TIME_SINCE",
|
||||||
|
"REF",
|
||||||
|
"LAG",
|
||||||
|
"PREV",
|
||||||
|
"SHIFT",
|
||||||
|
"ROLLING_MEAN",
|
||||||
|
"ROLLING_SUM",
|
||||||
|
"ROLLING_STD",
|
||||||
|
"ROLLING_MAX",
|
||||||
|
"ROLLING_MIN",
|
||||||
|
"ROLLING_MEDIAN",
|
||||||
|
"ROLLING_CORR",
|
||||||
|
"ROLLING_COV",
|
||||||
|
"AND",
|
||||||
|
"OR",
|
||||||
|
"NOT",
|
||||||
|
"XOR",
|
||||||
|
"ADD",
|
||||||
|
"SUB",
|
||||||
|
"MUL",
|
||||||
|
"DIV",
|
||||||
|
"ABS",
|
||||||
|
"MAX",
|
||||||
|
"MIN",
|
||||||
|
"LOG",
|
||||||
|
"SQRT",
|
||||||
|
"POWER",
|
||||||
|
"CUMMAX",
|
||||||
|
"CUMMIN",
|
||||||
|
"SIGN",
|
||||||
|
"IF",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub use crate::factor_event_catalog::catalog;
|
||||||
|
|
||||||
|
impl Frame {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
let n = self.timestamps.len();
|
||||||
|
if self.symbol.is_empty()
|
||||||
|
|| n == 0
|
||||||
|
|| n > 200_000
|
||||||
|
|| self.available_at.len() != n
|
||||||
|
|| self.fields.len() > 100
|
||||||
|
|| n.saturating_mul(self.fields.len()) > 1_000_000
|
||||||
|
{
|
||||||
|
return Err("factor_frame_invalid: identity/shape/limit".into());
|
||||||
|
}
|
||||||
|
if !["1d", "1w", "1m", "5m", "15m", "30m", "60m"].contains(&self.frequency.as_str()) {
|
||||||
|
return Err("factor_frame_invalid: unsupported_frequency".into());
|
||||||
|
}
|
||||||
|
for i in 0..n {
|
||||||
|
if (i > 0 && self.timestamps[i] <= self.timestamps[i - 1])
|
||||||
|
|| self.available_at[i] < self.timestamps[i]
|
||||||
|
|| self.available_at[i] > self.decision_at
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"factor_input_not_visible: {} index={i}",
|
||||||
|
self.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (field, values) in &self.fields {
|
||||||
|
if values.len() != n || values.iter().flatten().any(|v| !v.is_finite()) {
|
||||||
|
return Err(format!("factor_field_invalid: {} {field}", self.symbol));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(expr: &Expr, frame: &Frame) -> Result<Series, String> {
|
||||||
|
frame.validate()?;
|
||||||
|
fn cost(expr: &Expr, depth: usize, nodes: &mut usize) -> Result<usize, String> {
|
||||||
|
*nodes += 1;
|
||||||
|
if depth > 24 || *nodes > 256 {
|
||||||
|
return Err("factor_expression_size_exceeded".into());
|
||||||
|
}
|
||||||
|
let (children, own) = match expr {
|
||||||
|
Expr::Indicator {
|
||||||
|
inputs, parameters, ..
|
||||||
|
} => (
|
||||||
|
inputs.as_slice(),
|
||||||
|
parameters
|
||||||
|
.values()
|
||||||
|
.filter_map(Value::as_u64)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(30)
|
||||||
|
.min(1_000_000) as usize,
|
||||||
|
),
|
||||||
|
Expr::Operator { args, window, .. } => (args.as_slice(), window.unwrap_or(1)),
|
||||||
|
_ => (&[][..], 1),
|
||||||
|
};
|
||||||
|
children.iter().try_fold(own, |total, child| {
|
||||||
|
Ok(total.saturating_add(cost(child, depth + 1, nodes)?))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if frame
|
||||||
|
.timestamps
|
||||||
|
.len()
|
||||||
|
.saturating_mul(cost(expr, 0, &mut 0)?)
|
||||||
|
> 20_000_000
|
||||||
|
{
|
||||||
|
return Err("factor_expression_compute_budget_exceeded".into());
|
||||||
|
}
|
||||||
|
evaluate_inner(expr, frame, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_inner(expr: &Expr, frame: &Frame, depth: usize) -> Result<Series, String> {
|
||||||
|
if depth > 24 {
|
||||||
|
return Err("factor_expression_too_deep".into());
|
||||||
|
}
|
||||||
|
match expr {
|
||||||
|
Expr::Number { value } if value.is_finite() => Ok(Series {
|
||||||
|
value_type: ValueType::Number,
|
||||||
|
values: vec![Some(*value); frame.timestamps.len()],
|
||||||
|
}),
|
||||||
|
Expr::Number { .. } => Err("factor_constant_nonfinite".into()),
|
||||||
|
Expr::Field { name } => Ok(Series {
|
||||||
|
value_type: ValueType::Number,
|
||||||
|
values: frame
|
||||||
|
.fields
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| format!("factor_source_field_missing: {} {name}", frame.symbol))?
|
||||||
|
.clone(),
|
||||||
|
}),
|
||||||
|
Expr::Indicator {
|
||||||
|
name,
|
||||||
|
inputs,
|
||||||
|
parameters,
|
||||||
|
output,
|
||||||
|
} => indicator(name, inputs, parameters, *output, frame, depth),
|
||||||
|
Expr::Operator { name, args, window } => {
|
||||||
|
if args.len() > 16 {
|
||||||
|
return Err("factor_operator_arity_exceeded".into());
|
||||||
|
}
|
||||||
|
let args = args
|
||||||
|
.iter()
|
||||||
|
.map(|a| evaluate_inner(a, frame, depth + 1))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
operator(name, &args, *window, frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indicator(
|
||||||
|
name: &str,
|
||||||
|
inputs: &[Expr],
|
||||||
|
parameters: &BTreeMap<String, Value>,
|
||||||
|
output: usize,
|
||||||
|
frame: &Frame,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<Series, String> {
|
||||||
|
let id =
|
||||||
|
abstract_api::get_func_handle(name).ok_or_else(|| format!("indicator_unknown: {name}"))?;
|
||||||
|
let info = id.info();
|
||||||
|
if output >= info.outputs.len() {
|
||||||
|
return Err("indicator_output_invalid".into());
|
||||||
|
}
|
||||||
|
let real_count = info
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.kind == InputType::Real)
|
||||||
|
.count();
|
||||||
|
if inputs.len() != real_count || info.inputs.iter().any(|i| i.kind == InputType::Integer) {
|
||||||
|
return Err(format!(
|
||||||
|
"indicator_inputs_invalid: {name} expects {real_count} real series"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut data = inputs
|
||||||
|
.iter()
|
||||||
|
.map(|a| evaluate_inner(a, frame, depth + 1))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
if data.iter().any(|s| s.value_type != ValueType::Number) {
|
||||||
|
return Err("indicator_requires_numeric_input".into());
|
||||||
|
}
|
||||||
|
let price_names = ["open", "high", "low", "close", "volume", "open_interest"];
|
||||||
|
let flags = info
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.kind == InputType::Price)
|
||||||
|
.fold(0, |v, i| v | i.flags.0);
|
||||||
|
let mut price_indices = [None; 6];
|
||||||
|
for (i, field) in price_names.iter().enumerate() {
|
||||||
|
if flags & (1 << i) != 0 {
|
||||||
|
price_indices[i] = Some(data.len());
|
||||||
|
data.push(evaluate_inner(
|
||||||
|
&Expr::Field {
|
||||||
|
name: (*field).into(),
|
||||||
|
},
|
||||||
|
frame,
|
||||||
|
depth + 1,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let core = Core::new();
|
||||||
|
let mut validation = id.new_call(&core);
|
||||||
|
for (key, v) in parameters {
|
||||||
|
let slot = info
|
||||||
|
.opt_inputs
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.param_name == key)
|
||||||
|
.ok_or_else(|| format!("indicator_parameter_unknown: {name}.{key}"))?;
|
||||||
|
match info.opt_inputs[slot].kind {
|
||||||
|
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
|
||||||
|
let v = v
|
||||||
|
.as_i64()
|
||||||
|
.and_then(|v| i32::try_from(v).ok())
|
||||||
|
.ok_or("indicator_parameter_requires_integer")?;
|
||||||
|
validation.set_opt(slot, v).map_err(|e| format!("{e:?}"))?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
validation
|
||||||
|
.set_opt(
|
||||||
|
slot,
|
||||||
|
v.as_f64()
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
.ok_or("indicator_parameter_requires_finite_number")?,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lookback = validation
|
||||||
|
.lookback()
|
||||||
|
.map_err(|e| format!("indicator_parameter_invalid: {name} {e:?}"))?;
|
||||||
|
let n = frame.timestamps.len();
|
||||||
|
let mut result = vec![None; n];
|
||||||
|
let mut start = 0;
|
||||||
|
// Never bridge missing source observations. Recursive indicators rewarm after a gap.
|
||||||
|
while start < n {
|
||||||
|
if data.iter().any(|s| s.values[start].is_none()) {
|
||||||
|
start += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut end = start + 1;
|
||||||
|
while end < n && data.iter().all(|s| s.values[end].is_some()) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end - start <= lookback {
|
||||||
|
start = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let arrays = data
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
s.values[start..end]
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.unwrap())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut float_out = (0..info.outputs.len())
|
||||||
|
.map(|_| vec![0.0; end - start])
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut int_out = (0..info.outputs.len())
|
||||||
|
.map(|_| vec![0i32; end - start])
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut call = id.new_call(&core);
|
||||||
|
for (key, v) in parameters {
|
||||||
|
let slot = info
|
||||||
|
.opt_inputs
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.param_name == key)
|
||||||
|
.unwrap();
|
||||||
|
match info.opt_inputs[slot].kind {
|
||||||
|
OptInputType::IntegerRange { .. } | OptInputType::IntegerList { .. } => {
|
||||||
|
call.set_opt(slot, v.as_i64().unwrap() as i32)
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
call.set_opt(slot, v.as_f64().unwrap())
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut real_slot = 0;
|
||||||
|
for (slot, i) in info.inputs.iter().enumerate() {
|
||||||
|
if i.kind == InputType::Real {
|
||||||
|
call.set_input(slot, &arrays[real_slot])
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
real_slot += 1;
|
||||||
|
} else {
|
||||||
|
let p = price_indices.map(|i| i.map(|i| arrays[i].as_slice()));
|
||||||
|
call.set_price_input(slot, p[0], p[1], p[2], p[3], p[4], p[5])
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (slot, (floats, ints)) in float_out.iter_mut().zip(int_out.iter_mut()).enumerate() {
|
||||||
|
if info.outputs[slot].kind == OutputType::Real {
|
||||||
|
call.set_output(slot, floats)
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
} else {
|
||||||
|
call.set_int_output(slot, ints)
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let range = call
|
||||||
|
.call(0, end - start - 1)
|
||||||
|
.map_err(|e| format!("indicator_failed: {name} {e:?}"))?;
|
||||||
|
drop(call);
|
||||||
|
for j in 0..range.count {
|
||||||
|
let value = if info.outputs[output].kind == OutputType::Real {
|
||||||
|
float_out[output][j]
|
||||||
|
} else {
|
||||||
|
int_out[output][j] as f64
|
||||||
|
};
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err(format!(
|
||||||
|
"indicator_nonfinite: {name} index={}",
|
||||||
|
start + range.beg_idx + j
|
||||||
|
));
|
||||||
|
}
|
||||||
|
result[start + range.beg_idx + j] = Some(value);
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
Ok(Series {
|
||||||
|
value_type: ValueType::Number,
|
||||||
|
values: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn average(v: &[f64]) -> f64 {
|
||||||
|
v[0] + v
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.map(|x| (x - v[0]) / v.len() as f64)
|
||||||
|
.sum::<f64>()
|
||||||
|
}
|
||||||
|
fn slope(v: &[f64]) -> f64 {
|
||||||
|
let x = (v.len() - 1) as f64 / 2.0;
|
||||||
|
let y = average(v);
|
||||||
|
let num = v
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| (i as f64 - x) * (v - y))
|
||||||
|
.sum::<f64>();
|
||||||
|
let den = (0..v.len()).map(|i| (i as f64 - x).powi(2)).sum::<f64>();
|
||||||
|
num / den
|
||||||
|
}
|
||||||
|
fn boolean(v: bool) -> Option<f64> {
|
||||||
|
Some(if v { 1.0 } else { 0.0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operator(
|
||||||
|
name: &str,
|
||||||
|
args: &[Series],
|
||||||
|
window: Option<usize>,
|
||||||
|
frame: &Frame,
|
||||||
|
) -> Result<Series, String> {
|
||||||
|
if !OPERATORS.contains(&name) {
|
||||||
|
return Err(format!("operator_not_registered: {name}"));
|
||||||
|
}
|
||||||
|
let bool_input = matches!(
|
||||||
|
name,
|
||||||
|
"AND"
|
||||||
|
| "OR"
|
||||||
|
| "NOT"
|
||||||
|
| "XOR"
|
||||||
|
| "COUNT"
|
||||||
|
| "COUNT_TRUE"
|
||||||
|
| "CONSECUTIVE"
|
||||||
|
| "BARS_SINCE"
|
||||||
|
| "DURATION"
|
||||||
|
| "DAYS_SINCE"
|
||||||
|
| "TIME_SINCE"
|
||||||
|
);
|
||||||
|
let lag = matches!(name, "REF" | "LAG" | "PREV" | "SHIFT");
|
||||||
|
if args.is_empty()
|
||||||
|
|| (name == "IF"
|
||||||
|
&& (args.len() != 3
|
||||||
|
|| args[0].value_type != ValueType::Boolean
|
||||||
|
|| args[1].value_type != args[2].value_type))
|
||||||
|
|| (!lag
|
||||||
|
&& name != "IF"
|
||||||
|
&& args
|
||||||
|
.iter()
|
||||||
|
.any(|a| (a.value_type == ValueType::Boolean) != bool_input))
|
||||||
|
{
|
||||||
|
return Err(format!("operator_input_type_invalid: {name}"));
|
||||||
|
}
|
||||||
|
let arity = match name {
|
||||||
|
"BETWEEN" | "OUTSIDE" | "IF" => 3,
|
||||||
|
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" | "CROSS_ABOVE" | "CROSS_BELOW"
|
||||||
|
| "BREAK_ABOVE" | "BREAK_BELOW" | "ADD" | "SUB" | "MUL" | "DIV" | "MAX" | "MIN"
|
||||||
|
| "POWER" | "XOR" | "ROLLING_CORR" | "ROLLING_COV" | "NEAR_HIGH" | "NEAR_LOW"
|
||||||
|
| "BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => 2,
|
||||||
|
"AND" | "OR" => args.len(),
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
if args.len() != arity {
|
||||||
|
return Err(format!("operator_arity_invalid: {name}"));
|
||||||
|
}
|
||||||
|
let windowed = matches!(
|
||||||
|
name,
|
||||||
|
"BREAK_HIGH"
|
||||||
|
| "BREAK_LOW"
|
||||||
|
| "RISING"
|
||||||
|
| "FALLING"
|
||||||
|
| "NON_DECREASING"
|
||||||
|
| "NON_INCREASING"
|
||||||
|
| "SLOPE"
|
||||||
|
| "SLOPE_CHANGE"
|
||||||
|
| "HHV"
|
||||||
|
| "LLV"
|
||||||
|
| "ARGMAX"
|
||||||
|
| "ARGMIN"
|
||||||
|
| "DISTANCE_TO_HIGH"
|
||||||
|
| "DISTANCE_TO_LOW"
|
||||||
|
| "NEW_HIGH"
|
||||||
|
| "NEW_LOW"
|
||||||
|
| "NEAR_HIGH"
|
||||||
|
| "NEAR_LOW"
|
||||||
|
| "BULLISH_DIVERGENCE"
|
||||||
|
| "BEARISH_DIVERGENCE"
|
||||||
|
| "ZSCORE"
|
||||||
|
| "STANDARDIZE"
|
||||||
|
| "MINMAX"
|
||||||
|
| "NORMALIZE"
|
||||||
|
| "COUNT"
|
||||||
|
| "COUNT_TRUE"
|
||||||
|
) || name.starts_with("ROLLING_");
|
||||||
|
let n = window.unwrap_or(1);
|
||||||
|
if n == 0
|
||||||
|
|| n > 10_000
|
||||||
|
|| (windowed && window.is_none())
|
||||||
|
|| (matches!(
|
||||||
|
name,
|
||||||
|
"SLOPE"
|
||||||
|
| "SLOPE_CHANGE"
|
||||||
|
| "ZSCORE"
|
||||||
|
| "STANDARDIZE"
|
||||||
|
| "ROLLING_STD"
|
||||||
|
| "ROLLING_CORR"
|
||||||
|
| "ROLLING_COV"
|
||||||
|
) && n < 2)
|
||||||
|
{
|
||||||
|
return Err(format!("operator_window_invalid: {name}"));
|
||||||
|
}
|
||||||
|
let returns_bool = matches!(
|
||||||
|
name,
|
||||||
|
"GT" | "GTE"
|
||||||
|
| "LT"
|
||||||
|
| "LTE"
|
||||||
|
| "EQ"
|
||||||
|
| "NEQ"
|
||||||
|
| "BETWEEN"
|
||||||
|
| "OUTSIDE"
|
||||||
|
| "CROSS_ABOVE"
|
||||||
|
| "CROSS_BELOW"
|
||||||
|
| "BREAK_ABOVE"
|
||||||
|
| "BREAK_BELOW"
|
||||||
|
| "BREAK_HIGH"
|
||||||
|
| "BREAK_LOW"
|
||||||
|
| "RISING"
|
||||||
|
| "FALLING"
|
||||||
|
| "NON_DECREASING"
|
||||||
|
| "NON_INCREASING"
|
||||||
|
| "TURN_UP"
|
||||||
|
| "TURN_DOWN"
|
||||||
|
| "BOTTOM_REVERSAL"
|
||||||
|
| "TOP_REVERSAL"
|
||||||
|
| "NEW_HIGH"
|
||||||
|
| "NEW_LOW"
|
||||||
|
| "NEAR_HIGH"
|
||||||
|
| "NEAR_LOW"
|
||||||
|
| "BULLISH_DIVERGENCE"
|
||||||
|
| "BEARISH_DIVERGENCE"
|
||||||
|
| "AND"
|
||||||
|
| "OR"
|
||||||
|
| "NOT"
|
||||||
|
| "XOR"
|
||||||
|
);
|
||||||
|
let len = frame.timestamps.len();
|
||||||
|
let mut out = vec![None; len];
|
||||||
|
let mut last_true = None;
|
||||||
|
let mut consecutive = Some(0usize);
|
||||||
|
let mut extreme: Option<f64> = None;
|
||||||
|
let mut cumulative_complete = true;
|
||||||
|
for i in 0..len {
|
||||||
|
let a = args[0].values[i];
|
||||||
|
let b = args.get(1).and_then(|a| a.values[i]);
|
||||||
|
let at = |j: usize| args[0].values.get(j).copied().flatten();
|
||||||
|
let history = |end: usize, count: usize| -> Option<Vec<f64>> {
|
||||||
|
if end < count {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
args[0].values[end - count..end].iter().copied().collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out[i] = match name {
|
||||||
|
"IF" => a.and_then(|a| {
|
||||||
|
if a == 1.0 {
|
||||||
|
args[1].values[i]
|
||||||
|
} else {
|
||||||
|
args[2].values[i]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"SIGN" => a.map(|v| {
|
||||||
|
if v == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if v > 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
-1.0
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"CUMMAX" | "CUMMIN" => {
|
||||||
|
cumulative_complete &= a.is_some();
|
||||||
|
extreme = a.filter(|_| cumulative_complete).map(|v| {
|
||||||
|
extreme.map_or(v, |p| if name == "CUMMAX" { p.max(v) } else { p.min(v) })
|
||||||
|
});
|
||||||
|
extreme
|
||||||
|
}
|
||||||
|
"AND" => {
|
||||||
|
if args.iter().any(|a| a.values[i] == Some(0.0)) {
|
||||||
|
Some(0.0)
|
||||||
|
} else if args.iter().any(|a| a.values[i].is_none()) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"OR" => {
|
||||||
|
if args.iter().any(|a| a.values[i] == Some(1.0)) {
|
||||||
|
Some(1.0)
|
||||||
|
} else if args.iter().any(|a| a.values[i].is_none()) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"NOT" => a.map(|v| 1.0 - v),
|
||||||
|
"XOR" => a.zip(b).and_then(|(a, b)| boolean(a != b)),
|
||||||
|
"GT" | "GTE" | "LT" | "LTE" | "EQ" | "NEQ" => a.zip(b).and_then(|(a, b)| {
|
||||||
|
boolean(match name {
|
||||||
|
"GT" => a > b,
|
||||||
|
"GTE" => a >= b,
|
||||||
|
"LT" => a < b,
|
||||||
|
"LTE" => a <= b,
|
||||||
|
"EQ" => a == b,
|
||||||
|
_ => a != b,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
"BETWEEN" | "OUTSIDE" => a.zip(b).zip(args[2].values[i]).and_then(|((a, b), c)| {
|
||||||
|
if b > c {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
boolean((a >= b && a <= c) == (name == "BETWEEN"))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"CROSS_ABOVE" | "CROSS_BELOW" | "BREAK_ABOVE" | "BREAK_BELOW" => {
|
||||||
|
if i == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
a.zip(b).zip(at(i - 1).zip(args[1].values[i - 1])).and_then(
|
||||||
|
|((a, b), (p, q))| {
|
||||||
|
boolean(if name.ends_with("ABOVE") {
|
||||||
|
p <= q && a > b
|
||||||
|
} else {
|
||||||
|
p >= q && a < b
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"REF" | "LAG" | "PREV" | "SHIFT" => i.checked_sub(n).and_then(at),
|
||||||
|
"CHANGE" | "DIFF" | "DELTA" | "PCT_CHANGE" | "LOG_RETURN" => a
|
||||||
|
.zip(i.checked_sub(n).and_then(at))
|
||||||
|
.and_then(|(a, p)| match name {
|
||||||
|
"PCT_CHANGE" => {
|
||||||
|
if p == 0.0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(a / p - 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"LOG_RETURN" => {
|
||||||
|
if a <= 0.0 || p <= 0.0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((a / p).ln())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Some(a - p),
|
||||||
|
}),
|
||||||
|
"ACCELERATION" => a
|
||||||
|
.zip(i.checked_sub(n).and_then(at))
|
||||||
|
.zip(i.checked_sub(n * 2).and_then(at))
|
||||||
|
.map(|((a, p), q)| a - 2.0 * p + q),
|
||||||
|
"BULLISH_DIVERGENCE" | "BEARISH_DIVERGENCE" => {
|
||||||
|
if i < n || n < 4 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let price: Option<Vec<f64>> =
|
||||||
|
args[0].values[i - n..=i].iter().copied().collect();
|
||||||
|
let indicator: Option<Vec<f64>> =
|
||||||
|
args[1].values[i - n..=i].iter().copied().collect();
|
||||||
|
price.zip(indicator).and_then(|(price, indicator)| {
|
||||||
|
let low = name == "BULLISH_DIVERGENCE";
|
||||||
|
let pivots = (1..n)
|
||||||
|
.filter(|&j| {
|
||||||
|
if low {
|
||||||
|
price[j] < price[j - 1] && price[j] < price[j + 1]
|
||||||
|
} else {
|
||||||
|
price[j] > price[j - 1] && price[j] > price[j + 1]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if pivots.last() != Some(&(n - 1)) || pivots.len() < 2 {
|
||||||
|
return boolean(false);
|
||||||
|
}
|
||||||
|
let a = pivots[pivots.len() - 2];
|
||||||
|
let b = n - 1;
|
||||||
|
boolean(if low {
|
||||||
|
price[b] < price[a] && indicator[b] > indicator[a]
|
||||||
|
} else {
|
||||||
|
price[b] > price[a] && indicator[b] < indicator[a]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"TURN_UP" | "TURN_DOWN" | "BOTTOM_REVERSAL" | "TOP_REVERSAL" => {
|
||||||
|
if i < 2 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
a.zip(at(i - 1)).zip(at(i - 2)).and_then(|((a, p), q)| {
|
||||||
|
if name == "ACCELERATION" {
|
||||||
|
Some(a - 2.0 * p + q)
|
||||||
|
} else {
|
||||||
|
boolean(if matches!(name, "TURN_UP" | "BOTTOM_REVERSAL") {
|
||||||
|
p < q && a > p
|
||||||
|
} else {
|
||||||
|
p > q && a < p
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ABS" => a.map(f64::abs),
|
||||||
|
"LOG" => a.filter(|v| *v > 0.0).map(f64::ln),
|
||||||
|
"SQRT" => a.filter(|v| *v >= 0.0).map(f64::sqrt),
|
||||||
|
"ADD" => a.zip(b).map(|(a, b)| a + b),
|
||||||
|
"SUB" => a.zip(b).map(|(a, b)| a - b),
|
||||||
|
"MUL" => a.zip(b).map(|(a, b)| a * b),
|
||||||
|
"DIV" => a.zip(b).filter(|(_, b)| *b != 0.0).map(|(a, b)| a / b),
|
||||||
|
"MAX" => a.zip(b).map(|(a, b)| a.max(b)),
|
||||||
|
"MIN" => a.zip(b).map(|(a, b)| a.min(b)),
|
||||||
|
"POWER" => a.zip(b).map(|(a, b)| a.powf(b)),
|
||||||
|
"BARS_SINCE" | "DAYS_SINCE" | "TIME_SINCE" => {
|
||||||
|
if a == Some(1.0) {
|
||||||
|
last_true = Some(i);
|
||||||
|
}
|
||||||
|
if a.is_none() {
|
||||||
|
last_true = None;
|
||||||
|
}
|
||||||
|
last_true.map(|t| {
|
||||||
|
if name == "BARS_SINCE" {
|
||||||
|
(i - t) as f64
|
||||||
|
} else {
|
||||||
|
let secs = (frame.timestamps[i] - frame.timestamps[t]).num_seconds() as f64;
|
||||||
|
if name == "DAYS_SINCE" {
|
||||||
|
secs / 86400.0
|
||||||
|
} else {
|
||||||
|
secs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"CONSECUTIVE" | "DURATION" => {
|
||||||
|
consecutive = match a {
|
||||||
|
Some(1.0) => consecutive.map(|v| v + 1),
|
||||||
|
Some(_) => Some(0),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
consecutive.map(|v| v as f64)
|
||||||
|
}
|
||||||
|
"BREAK_HIGH" | "NEW_HIGH" | "BREAK_LOW" | "NEW_LOW" => {
|
||||||
|
a.zip(history(i, n)).and_then(|(a, v)| {
|
||||||
|
boolean(if matches!(name, "BREAK_HIGH" | "NEW_HIGH") {
|
||||||
|
a > v.into_iter().fold(f64::NEG_INFINITY, f64::max)
|
||||||
|
} else {
|
||||||
|
a < v.into_iter().fold(f64::INFINITY, f64::min)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"RISING" | "FALLING" | "NON_DECREASING" | "NON_INCREASING" => history(i + 1, n + 1)
|
||||||
|
.and_then(|v| {
|
||||||
|
boolean(v.windows(2).all(|p| match name {
|
||||||
|
"RISING" => p[1] > p[0],
|
||||||
|
"FALLING" => p[1] < p[0],
|
||||||
|
"NON_DECREASING" => p[1] >= p[0],
|
||||||
|
_ => p[1] <= p[0],
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
"SLOPE_CHANGE" => history(i + 1, n)
|
||||||
|
.zip(history(i, n))
|
||||||
|
.map(|(a, b)| slope(&a) - slope(&b)),
|
||||||
|
_ => history(i + 1, n).and_then(|mut v| {
|
||||||
|
let mean = average(&v);
|
||||||
|
let lo = v.iter().copied().fold(f64::INFINITY, f64::min);
|
||||||
|
let hi = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
let variance = v.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
|
||||||
|
match name {
|
||||||
|
"HHV" | "ROLLING_MAX" => Some(hi),
|
||||||
|
"LLV" | "ROLLING_MIN" => Some(lo),
|
||||||
|
"ARGMAX" => v.iter().rposition(|x| *x == hi).map(|p| (n - 1 - p) as f64),
|
||||||
|
"ARGMIN" => v.iter().rposition(|x| *x == lo).map(|p| (n - 1 - p) as f64),
|
||||||
|
"DISTANCE_TO_HIGH" => {
|
||||||
|
if hi == 0.0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(v[n - 1] / hi - 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"DISTANCE_TO_LOW" => {
|
||||||
|
if lo == 0.0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(v[n - 1] / lo - 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"NEAR_HIGH" | "NEAR_LOW" => b.filter(|b| *b >= 0.0).and_then(|b| {
|
||||||
|
let base = if name == "NEAR_HIGH" { hi } else { lo };
|
||||||
|
if base == 0.0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
boolean((v[n - 1] / base - 1.0).abs() <= b)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"ZSCORE" | "STANDARDIZE" => {
|
||||||
|
if variance == 0.0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((v[n - 1] - mean) / variance.sqrt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"MINMAX" | "NORMALIZE" => {
|
||||||
|
if hi == lo {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((v[n - 1] - lo) / (hi - lo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ROLLING_MEAN" => Some(mean),
|
||||||
|
"ROLLING_SUM" | "COUNT" | "COUNT_TRUE" => Some(v.iter().sum()),
|
||||||
|
"ROLLING_STD" => Some(variance.sqrt()),
|
||||||
|
"ROLLING_MEDIAN" => {
|
||||||
|
v.sort_by(f64::total_cmp);
|
||||||
|
Some(if n % 2 == 1 {
|
||||||
|
v[n / 2]
|
||||||
|
} else {
|
||||||
|
(v[n / 2 - 1] + v[n / 2]) / 2.0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"SLOPE" => Some(slope(&v)),
|
||||||
|
"ROLLING_CORR" | "ROLLING_COV" => {
|
||||||
|
let b: Option<Vec<f64>> =
|
||||||
|
args[1].values[i + 1 - n..=i].iter().copied().collect();
|
||||||
|
b.and_then(|b| {
|
||||||
|
let bm = average(&b);
|
||||||
|
let cov = v
|
||||||
|
.iter()
|
||||||
|
.zip(&b)
|
||||||
|
.map(|(a, b)| (a - mean) * (b - bm))
|
||||||
|
.sum::<f64>()
|
||||||
|
/ n as f64;
|
||||||
|
if name == "ROLLING_COV" {
|
||||||
|
Some(cov)
|
||||||
|
} else {
|
||||||
|
let bv = b.iter().map(|b| (b - bm).powi(2)).sum::<f64>() / n as f64;
|
||||||
|
let d = (variance * bv).sqrt();
|
||||||
|
if d == 0.0 { None } else { Some(cov / d) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
.filter(|v| v.is_finite());
|
||||||
|
}
|
||||||
|
Ok(Series {
|
||||||
|
value_type: if name == "IF" {
|
||||||
|
args[1].value_type
|
||||||
|
} else if lag {
|
||||||
|
args[0].value_type
|
||||||
|
} else if returns_bool {
|
||||||
|
ValueType::Boolean
|
||||||
|
} else {
|
||||||
|
ValueType::Number
|
||||||
|
},
|
||||||
|
values: out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "factor_events_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use crate::factor_event_catalog::parameter_domain;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
|
||||||
|
for function in abstract_api::funcs() {
|
||||||
|
let handle = abstract_api::get_func_handle(function.name).unwrap();
|
||||||
|
let core = Core::new();
|
||||||
|
let mut call = handle.new_call(&core);
|
||||||
|
for (index, parameter) in function.opt_inputs.iter().enumerate() {
|
||||||
|
let domain = parameter_domain(parameter.kind);
|
||||||
|
let default = domain["default"].as_f64().unwrap();
|
||||||
|
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
|
||||||
|
if let Some(choices) = domain.get("choices") {
|
||||||
|
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
|
||||||
|
} else {
|
||||||
|
assert!(default >= domain["minimum"].as_f64().unwrap());
|
||||||
|
assert!(default <= domain["maximum"].as_f64().unwrap());
|
||||||
|
}
|
||||||
|
if domain["value_type"] == "integer" {
|
||||||
|
assert_eq!(default.fract(), 0.0);
|
||||||
|
call.set_opt(index, default as i32).unwrap();
|
||||||
|
} else {
|
||||||
|
call.set_opt(index, default).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(call.lookback().is_ok(), "{}", function.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
|
||||||
|
let catalog = catalog();
|
||||||
|
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
|
||||||
|
let indicators = catalog["indicators"].as_array().unwrap();
|
||||||
|
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
|
||||||
|
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
|
||||||
|
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
|
||||||
|
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
|
||||||
|
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(values: Vec<Option<f64>>) -> Frame {
|
||||||
|
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
|
||||||
|
let times = (0..values.len())
|
||||||
|
.map(|i| start + chrono::Duration::days(i as i64))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Frame {
|
||||||
|
symbol: "TEST".into(),
|
||||||
|
frequency: "1d".into(),
|
||||||
|
decision_at: *times.last().unwrap(),
|
||||||
|
available_at: times.clone(),
|
||||||
|
timestamps: times,
|
||||||
|
fields: BTreeMap::from([("close".into(), values)]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn expr(v: Value) -> Expr {
|
||||||
|
serde_json::from_value(v).unwrap()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ta_sma_real_values_and_parameter_validation() {
|
||||||
|
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &frame).unwrap().values,
|
||||||
|
vec![None, None, Some(2.0), Some(3.0)]
|
||||||
|
);
|
||||||
|
let bad = expr(
|
||||||
|
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
evaluate(&bad, &frame)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("parameter_unknown")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn cross_is_event_not_state_and_never_uses_future() {
|
||||||
|
let f = frame(vec![
|
||||||
|
Some(9.0),
|
||||||
|
Some(10.0),
|
||||||
|
Some(11.0),
|
||||||
|
Some(12.0),
|
||||||
|
Some(8.0),
|
||||||
|
]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &f).unwrap().values,
|
||||||
|
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
|
||||||
|
);
|
||||||
|
let mut invalid = f.clone();
|
||||||
|
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
|
||||||
|
assert!(evaluate(&e, &invalid).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn missing_is_not_zero_and_breakout_excludes_current() {
|
||||||
|
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &f).unwrap().values,
|
||||||
|
vec![None, None, Some(1.0), None, None]
|
||||||
|
);
|
||||||
|
let zero = expr(
|
||||||
|
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
evaluate(&zero, &f)
|
||||||
|
.unwrap()
|
||||||
|
.values
|
||||||
|
.iter()
|
||||||
|
.all(Option::is_none)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
|
||||||
|
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&e, &f).unwrap().values,
|
||||||
|
vec![None, Some(1.0), None, None, Some(2.0)]
|
||||||
|
);
|
||||||
|
let e = expr(
|
||||||
|
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||||
|
);
|
||||||
|
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn no_event_has_no_bars_since_and_type_errors_reject() {
|
||||||
|
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
|
||||||
|
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
|
||||||
|
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
|
||||||
|
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||||
|
assert!(
|
||||||
|
evaluate(
|
||||||
|
&expr(
|
||||||
|
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
|
||||||
|
),
|
||||||
|
&f
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let c = catalog();
|
||||||
|
assert!(c["indicators"].as_array().unwrap().len() > 190);
|
||||||
|
assert_eq!(c["live_routing"], false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::TradingCalendar;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TradingActionOrigin {
|
||||||
|
Strategy,
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AutomaticTradeProtection {
|
||||||
|
#[serde(default, deserialize_with = "optional_days")]
|
||||||
|
pub buy_protection_days: u32,
|
||||||
|
#[serde(default, deserialize_with = "optional_days")]
|
||||||
|
pub sell_cooldown_days: u32,
|
||||||
|
#[serde(default, deserialize_with = "optional_days")]
|
||||||
|
pub max_holding_days: u32,
|
||||||
|
#[serde(default, deserialize_with = "optional_locks")]
|
||||||
|
pub locks: Vec<AutomaticTradeLock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<AutomaticTradeProtection, D::Error> {
|
||||||
|
Ok(Option::<AutomaticTradeProtection>::deserialize(deserializer)?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||||
|
let raw = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
if raw.is_null() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
raw.as_f64()
|
||||||
|
.filter(|value| {
|
||||||
|
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0
|
||||||
|
})
|
||||||
|
.map(|value| value as u32)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_locks<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Vec<AutomaticTradeLock>, D::Error> {
|
||||||
|
Ok(Option::<Vec<AutomaticTradeLock>>::deserialize(deserializer)?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AutomaticTradeLock {
|
||||||
|
pub symbol: String,
|
||||||
|
pub start_date: NaiveDate,
|
||||||
|
pub end_date: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct HoldingLifecycleEvidence {
|
||||||
|
pub has_position: bool,
|
||||||
|
pub opened_date: Option<NaiveDate>,
|
||||||
|
pub last_buy_date: Option<NaiveDate>,
|
||||||
|
pub last_sell_date: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct AutomaticTradePermission {
|
||||||
|
pub buy_denial: Option<&'static str>,
|
||||||
|
pub sell_denial: Option<&'static str>,
|
||||||
|
pub max_holding_exit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutomaticTradeProtection {
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.buy_protection_days > 0
|
||||||
|
|| self.sell_cooldown_days > 0
|
||||||
|
|| self.max_holding_days > 0
|
||||||
|
|| !self.locks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if [
|
||||||
|
self.buy_protection_days,
|
||||||
|
self.sell_cooldown_days,
|
||||||
|
self.max_holding_days,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.any(|days| days > 3650)
|
||||||
|
{
|
||||||
|
return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into());
|
||||||
|
}
|
||||||
|
if self.locks.len() > 2000 {
|
||||||
|
return Err("automatic_trade_locks_limit: maximum 2000 intervals".into());
|
||||||
|
}
|
||||||
|
for lock in &self.locks {
|
||||||
|
let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| {
|
||||||
|
code.len() == 6
|
||||||
|
&& code.bytes().all(|ch| ch.is_ascii_digit())
|
||||||
|
&& matches!(venue, "SH" | "SZ" | "BJ")
|
||||||
|
});
|
||||||
|
if !valid_symbol {
|
||||||
|
return Err(format!(
|
||||||
|
"automatic_trade_lock_invalid_symbol: {}",
|
||||||
|
lock.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if lock.end_date.is_some_and(|end| end < lock.start_date) {
|
||||||
|
return Err(format!(
|
||||||
|
"automatic_trade_lock_invalid_interval: {}",
|
||||||
|
lock.symbol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
execution_date: NaiveDate,
|
||||||
|
evidence: &HoldingLifecycleEvidence,
|
||||||
|
calendar: &TradingCalendar,
|
||||||
|
) -> Result<AutomaticTradePermission, String> {
|
||||||
|
self.validate()?;
|
||||||
|
if self.locks.iter().any(|lock| {
|
||||||
|
lock.symbol == symbol
|
||||||
|
&& lock.start_date <= execution_date
|
||||||
|
&& lock.end_date.is_none_or(|end| execution_date <= end)
|
||||||
|
}) {
|
||||||
|
return Ok(AutomaticTradePermission {
|
||||||
|
buy_denial: Some("automatic_trade_locked"),
|
||||||
|
sell_denial: Some("automatic_trade_locked"),
|
||||||
|
max_holding_exit: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let elapsed = |date: NaiveDate| -> Result<usize, String> {
|
||||||
|
let start = calendar.index_of(date).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?;
|
||||||
|
end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}"))
|
||||||
|
};
|
||||||
|
let mut decision = AutomaticTradePermission::default();
|
||||||
|
if self.buy_protection_days > 0
|
||||||
|
&& evidence.has_position
|
||||||
|
&& let Some(date) = evidence.last_buy_date
|
||||||
|
&& elapsed(date)? <= self.buy_protection_days as usize
|
||||||
|
{
|
||||||
|
decision.sell_denial = Some("buy_fill_protection");
|
||||||
|
}
|
||||||
|
if self.sell_cooldown_days > 0
|
||||||
|
&& let Some(date) = evidence.last_sell_date
|
||||||
|
&& elapsed(date)? <= self.sell_cooldown_days as usize
|
||||||
|
{
|
||||||
|
decision.buy_denial = Some("sell_fill_cooldown");
|
||||||
|
}
|
||||||
|
if self.max_holding_days > 0 && evidence.has_position {
|
||||||
|
let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?;
|
||||||
|
decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize
|
||||||
|
&& decision.sell_denial.is_none();
|
||||||
|
if decision.max_holding_exit {
|
||||||
|
decision.buy_denial = Some("maximum_holding_exit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caller supplies origin from its authenticated execution path, never
|
||||||
|
/// from an untrusted order-body flag. Broker and ordinary risk checks remain.
|
||||||
|
pub fn evaluate_for_origin(
|
||||||
|
&self,
|
||||||
|
origin: TradingActionOrigin,
|
||||||
|
symbol: &str,
|
||||||
|
execution_date: NaiveDate,
|
||||||
|
evidence: &HoldingLifecycleEvidence,
|
||||||
|
calendar: &TradingCalendar,
|
||||||
|
) -> Result<AutomaticTradePermission, String> {
|
||||||
|
self.validate()?;
|
||||||
|
match origin {
|
||||||
|
TradingActionOrigin::Strategy => {
|
||||||
|
self.evaluate(symbol, execution_date, evidence, calendar)
|
||||||
|
}
|
||||||
|
TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn d(value: &str) -> NaiveDate {
|
||||||
|
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
|
||||||
|
}
|
||||||
|
fn calendar() -> TradingCalendar {
|
||||||
|
TradingCalendar::new(
|
||||||
|
[
|
||||||
|
"2026-09-11",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-15",
|
||||||
|
"2026-09-16",
|
||||||
|
"2026-09-17",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(d)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_complete_sessions_protect_through_wednesday_not_72_hours() {
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
buy_protection_days: 3,
|
||||||
|
sell_cooldown_days: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
has_position: true,
|
||||||
|
last_buy_date: Some(d("2026-09-11")),
|
||||||
|
last_sell_date: Some(d("2026-09-11")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] {
|
||||||
|
let decision = policy
|
||||||
|
.evaluate("000001.SZ", d(day), &evidence, &calendar())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decision.sell_denial, Some("buy_fill_protection"));
|
||||||
|
assert_eq!(decision.buy_denial, Some("sell_fill_cooldown"));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap(),
|
||||||
|
AutomaticTradePermission::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() {
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
max_holding_days: 1,
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d("2026-09-11"),
|
||||||
|
end_date: Some(d("2026-09-16")),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
has_position: true,
|
||||||
|
opened_date: Some(d("2026-09-11")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let locked = policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(locked.sell_denial, Some("automatic_trade_locked"));
|
||||||
|
assert!(!locked.max_holding_exit);
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar())
|
||||||
|
.unwrap()
|
||||||
|
.max_holding_exit
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap()
|
||||||
|
.max_holding_exit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() {
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
has_position: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
AutomaticTradeProtection::default()
|
||||||
|
.evaluate(
|
||||||
|
"000001.SZ",
|
||||||
|
d("2026-09-17"),
|
||||||
|
&evidence,
|
||||||
|
&TradingCalendar::new(vec![])
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
AutomaticTradePermission::default()
|
||||||
|
);
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
max_holding_days: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("opened_date_missing")
|
||||||
|
);
|
||||||
|
let evidence = HoldingLifecycleEvidence {
|
||||||
|
opened_date: Some(d("2026-09-10")),
|
||||||
|
..evidence
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
policy
|
||||||
|
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("calendar_missing")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() {
|
||||||
|
let policy = AutomaticTradeProtection {
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d("2026-09-11"),
|
||||||
|
end_date: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
policy
|
||||||
|
.evaluate_for_origin(
|
||||||
|
TradingActionOrigin::Manual,
|
||||||
|
"000001.SZ",
|
||||||
|
d("2026-09-14"),
|
||||||
|
&HoldingLifecycleEvidence::default(),
|
||||||
|
&calendar()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
AutomaticTradePermission::default()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
policy
|
||||||
|
.evaluate_for_origin(
|
||||||
|
TradingActionOrigin::Strategy,
|
||||||
|
"000001.SZ",
|
||||||
|
d("2026-09-14"),
|
||||||
|
&HoldingLifecycleEvidence::default(),
|
||||||
|
&calendar()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.buy_denial,
|
||||||
|
Some("automatic_trade_locked")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() {
|
||||||
|
let mut portfolio = crate::PortfolioState::new(100_000.0);
|
||||||
|
let position = portfolio.position_mut("000001.SZ");
|
||||||
|
position.buy(d("2026-09-11"), 100, 10.0);
|
||||||
|
position.buy(d("2026-09-14"), 200, 10.0);
|
||||||
|
position.sell(100, 10.0).unwrap();
|
||||||
|
assert_eq!(position.opened_date(), Some(d("2026-09-11")));
|
||||||
|
portfolio
|
||||||
|
.apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0)
|
||||||
|
.unwrap();
|
||||||
|
let successor = portfolio.position_mut("000002.SZ");
|
||||||
|
assert_eq!(successor.opened_date(), Some(d("2026-09-11")));
|
||||||
|
assert_eq!(successor.last_buy_date(), Some(d("2026-09-14")));
|
||||||
|
successor.sell(400, 5.0).unwrap();
|
||||||
|
assert_eq!(successor.opened_date(), None);
|
||||||
|
successor.buy(d("2026-09-17"), 100, 5.0);
|
||||||
|
assert_eq!(successor.opened_date(), Some(d("2026-09-17")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub fn listed_sector_is_kcb(value: &str) -> Option<bool> {
|
||||||
|
match value.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true),
|
||||||
|
"主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板"
|
||||||
|
| "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层"
|
||||||
|
| "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE"
|
||||||
|
| "BSE" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Instrument {
|
pub struct Instrument {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
@@ -21,17 +32,29 @@ impl Instrument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn minimum_order_quantity(&self) -> u32 {
|
pub fn minimum_order_quantity(&self) -> u32 {
|
||||||
match self.board.trim().to_ascii_uppercase().as_str() {
|
let board = self.board.trim();
|
||||||
"KSH" => 200,
|
if board.eq_ignore_ascii_case("KSH") {
|
||||||
"BJS" | "BJ" | "BJSE" => 100,
|
200
|
||||||
_ => self.effective_round_lot(),
|
} 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 {
|
pub fn order_step_size(&self) -> u32 {
|
||||||
match self.board.trim().to_ascii_uppercase().as_str() {
|
let board = self.board.trim();
|
||||||
"KSH" | "BJS" | "BJ" | "BJSE" => 1,
|
if board.eq_ignore_ascii_case("KSH")
|
||||||
_ => self.effective_round_lot(),
|
|| board.eq_ignore_ascii_case("BJS")
|
||||||
|
|| board.eq_ignore_ascii_case("BJ")
|
||||||
|
|| board.eq_ignore_ascii_case("BJSE")
|
||||||
|
{
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
self.effective_round_lot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +70,17 @@ impl Instrument {
|
|||||||
|
|
||||||
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
pub fn is_active_on(&self, date: NaiveDate) -> bool {
|
||||||
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
self.listed_at.is_none_or(|listed_at| listed_at <= date)
|
||||||
&& !self.is_delisted_before(date)
|
&& !self.is_delisted_on_or_before(date)
|
||||||
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
|
}
|
||||||
|
|
||||||
|
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
|
||||||
|
if self.listed_at.is_some_and(|listed| date < listed) {
|
||||||
|
Some("not_yet_listed")
|
||||||
|
} else if self.is_delisted_on_or_before(date) {
|
||||||
|
Some("delisted")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +88,69 @@ fn default_status() -> String {
|
|||||||
"active".to_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 lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
|
||||||
|
let mut item = instrument("BJS", 100);
|
||||||
|
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
|
||||||
|
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
|
||||||
|
item.listed_at = Some(listing);
|
||||||
|
item.delisted_at = Some(removal);
|
||||||
|
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
|
||||||
|
assert!(item.is_active_on(listing));
|
||||||
|
assert!(!item.is_active_on(removal));
|
||||||
|
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
|
||||||
|
item.delisted_at = None;
|
||||||
|
for status in ["delisting", "delisted", "inactive", "terminated"] {
|
||||||
|
item.status = status.into();
|
||||||
|
assert!(item.is_active_on(listing));
|
||||||
|
assert_eq!(item.dated_market_absence_reason(listing), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
|
||||||
|
let kcb = instrument(" kSh ", 100);
|
||||||
|
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 {
|
mod optional_date_format {
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
|||||||
+24
-13
@@ -2,6 +2,15 @@ pub mod broker;
|
|||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod cost;
|
pub mod cost;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
|
pub mod daily_patterns;
|
||||||
|
pub mod pattern_context;
|
||||||
|
pub mod session_events;
|
||||||
|
pub mod factor_events;
|
||||||
|
pub mod execution_capacity;
|
||||||
|
mod execution_schedule;
|
||||||
|
mod factor_event_catalog;
|
||||||
|
pub mod factor_cross_section;
|
||||||
|
pub mod market_event_context;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod event_bus;
|
pub mod event_bus;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
@@ -14,10 +23,13 @@ pub mod platform_expr_strategy;
|
|||||||
pub mod platform_runtime_schema;
|
pub mod platform_runtime_schema;
|
||||||
pub mod platform_strategy_spec;
|
pub mod platform_strategy_spec;
|
||||||
pub mod portfolio;
|
pub mod portfolio;
|
||||||
|
pub mod portfolio_loss;
|
||||||
pub mod risk_control;
|
pub mod risk_control;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod strategy;
|
pub mod strategy;
|
||||||
|
pub mod holding_policy;
|
||||||
|
pub mod signal_contract;
|
||||||
pub mod strategy_ai;
|
pub mod strategy_ai;
|
||||||
pub mod universe;
|
pub mod universe;
|
||||||
|
|
||||||
@@ -31,15 +43,15 @@ pub use data::{
|
|||||||
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
||||||
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, DividendRecord,
|
||||||
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
EligibleUniverseSnapshot, FactorTextValue, FactorValue, IntradayExecutionQuote,
|
||||||
IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar, PriceField, SecuritiesMarginRecord,
|
IntradayMarketSnapshotOverlay, IntradayOrderBookDepthLevel, NumericFactorMap, PriceBar,
|
||||||
SplitRecord, YieldCurvePoint,
|
PriceField, SecuritiesMarginRecord, SplitRecord, YieldCurvePoint,
|
||||||
};
|
};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
AnalyzerMonthlyReturnRow, AnalyzerPositionRow, AnalyzerReport, AnalyzerRiskSummary,
|
||||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||||
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
|
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
|
||||||
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
||||||
ProcessEventRetention,
|
ProcessEventRetention, backtest_execution_dates,
|
||||||
};
|
};
|
||||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
@@ -57,15 +69,14 @@ pub use futures::{
|
|||||||
};
|
};
|
||||||
pub use instrument::Instrument;
|
pub use instrument::Instrument;
|
||||||
pub use metrics::{
|
pub use metrics::{
|
||||||
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation,
|
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, compute_backtest_metrics,
|
||||||
compute_backtest_metrics,
|
|
||||||
};
|
};
|
||||||
pub use platform_expr_strategy::{
|
pub use platform_expr_strategy::{
|
||||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||||
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode,
|
||||||
PlatformUniverseActionKind,
|
PlatformTradeAction, PlatformUniverseActionKind,
|
||||||
};
|
};
|
||||||
pub use platform_runtime_schema::{
|
pub use platform_runtime_schema::{
|
||||||
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
||||||
@@ -78,13 +89,13 @@ pub use platform_strategy_spec::{
|
|||||||
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
|
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
|
||||||
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
|
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
|
||||||
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
|
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
|
||||||
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
|
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig, StrategyRebalanceSpec,
|
||||||
StrategyRebalanceSpec, StrategyRiskPolicySpec, StrategyRuntimeEnvironment,
|
StrategyRiskPolicySpec, StrategyRuntimeEnvironment, StrategyRuntimeExpressions,
|
||||||
StrategyRuntimeExpressions, StrategyRuntimeSpec, StrategyUniverseSpec,
|
StrategyRuntimeSpec, StrategyUniverseSpec, platform_expr_config_from_spec,
|
||||||
platform_expr_config_from_spec, platform_expr_config_from_value,
|
platform_expr_config_from_value, validate_strategy_risk_policy_fields,
|
||||||
validate_strategy_risk_policy_fields,
|
|
||||||
};
|
};
|
||||||
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
|
||||||
|
pub use portfolio_loss::{ClosedPortfolioSession, PortfolioLossConfig, PortfolioLossDecision, PortfolioLossError, PortfolioLossState};
|
||||||
pub use risk_control::{
|
pub use risk_control::{
|
||||||
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit, RiskCheckScope,
|
||||||
StaticRiskRuleConfig, TradingConstraintConfig,
|
StaticRiskRuleConfig, TradingConstraintConfig,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,13 +108,7 @@ pub fn compute_backtest_metrics(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let trade_days = equity_curve.len();
|
let trade_days = equity_curve.len();
|
||||||
let benchmark_start = if first_point.benchmark_prev_close.is_finite()
|
let benchmark_start = first_point.benchmark_reference_close();
|
||||||
&& first_point.benchmark_prev_close > f64::EPSILON
|
|
||||||
{
|
|
||||||
first_point.benchmark_prev_close
|
|
||||||
} else {
|
|
||||||
first_point.benchmark_close
|
|
||||||
};
|
|
||||||
let explicit_unit_nav = equity_curve.iter().any(|point| {
|
let explicit_unit_nav = equity_curve.iter().any(|point| {
|
||||||
point.external_cash_flow.abs() > f64::EPSILON
|
point.external_cash_flow.abs() > f64::EPSILON
|
||||||
|| (point.unit_nav.is_finite()
|
|| (point.unit_nav.is_finite()
|
||||||
@@ -564,7 +558,9 @@ fn alpha_beta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||||
let mut peak = 0.0_f64;
|
// NAV is measured against the pre-period capital. The first real loss
|
||||||
|
// must not become a new zero-drawdown baseline.
|
||||||
|
let mut peak = 1.0_f64;
|
||||||
let mut max_drawdown = 0.0_f64;
|
let mut max_drawdown = 0.0_f64;
|
||||||
let mut duration = 0_usize;
|
let mut duration = 0_usize;
|
||||||
let mut max_duration = 0_usize;
|
let mut max_duration = 0_usize;
|
||||||
@@ -773,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
|
||||||
|
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
|
||||||
|
assert!((drawdown + 0.1).abs() < 1e-12);
|
||||||
|
assert_eq!(duration, 2);
|
||||||
|
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
|
||||||
|
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
|
||||||
|
assert_eq!(drawdown_stats(&[]), (0.0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
|
||||||
|
let curve = vec![
|
||||||
|
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
|
||||||
|
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
|
||||||
|
];
|
||||||
|
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||||
|
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
|
||||||
|
assert_eq!(metrics.total_trade_days, 2);
|
||||||
|
assert_eq!(metrics.max_drawdown_duration_days, 2);
|
||||||
|
}
|
||||||
|
|
||||||
fn equity_point(
|
fn equity_point(
|
||||||
date: &str,
|
date: &str,
|
||||||
total_equity: f64,
|
total_equity: f64,
|
||||||
@@ -780,6 +798,7 @@ mod tests {
|
|||||||
benchmark_prev_close: f64,
|
benchmark_prev_close: f64,
|
||||||
) -> DailyEquityPoint {
|
) -> DailyEquityPoint {
|
||||||
DailyEquityPoint {
|
DailyEquityPoint {
|
||||||
|
signal_baseline: false,
|
||||||
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
||||||
cash: total_equity,
|
cash: total_equity,
|
||||||
market_value: 0.0,
|
market_value: 0.0,
|
||||||
@@ -804,11 +823,21 @@ mod tests {
|
|||||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signal_baseline_uses_same_close_for_strategy_and_benchmark() {
|
||||||
|
let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784);
|
||||||
|
baseline.signal_baseline=true;
|
||||||
|
let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)];
|
||||||
|
let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap();
|
||||||
|
assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).abs()<1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
|
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
|
||||||
let curve = vec![
|
let curve = vec![
|
||||||
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||||
DailyEquityPoint {
|
DailyEquityPoint {
|
||||||
|
signal_baseline: false,
|
||||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||||
cash: 220.0,
|
cash: 220.0,
|
||||||
market_value: 0.0,
|
market_value: 0.0,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub(crate) enum ValueType {
|
|||||||
pub(crate) enum Value {
|
pub(crate) enum Value {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Boolean(bool),
|
Boolean(bool),
|
||||||
|
Missing(ValueType),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Value {
|
impl Value {
|
||||||
@@ -18,20 +19,28 @@ impl Value {
|
|||||||
match self {
|
match self {
|
||||||
Self::Number(_) => ValueType::Number,
|
Self::Number(_) => ValueType::Number,
|
||||||
Self::Boolean(_) => ValueType::Boolean,
|
Self::Boolean(_) => ValueType::Boolean,
|
||||||
|
Self::Missing(value_type) => value_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_number(self) -> Option<f64> {
|
pub(crate) fn as_number(self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
Self::Number(value) => Some(value),
|
Self::Number(value) => Some(value),
|
||||||
Self::Boolean(_) => None,
|
Self::Boolean(_) | Self::Missing(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn as_bool(self) -> Option<bool> {
|
pub(crate) fn as_bool(self) -> Option<bool> {
|
||||||
match self {
|
match self {
|
||||||
Self::Boolean(value) => Some(value),
|
Self::Boolean(value) => Some(value),
|
||||||
Self::Number(_) => None,
|
Self::Number(_) | Self::Missing(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Number(value) if !value.is_finite() => Self::Missing(ValueType::Number),
|
||||||
|
value => value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +112,8 @@ enum BinaryOp {
|
|||||||
LessEqual,
|
LessEqual,
|
||||||
Greater,
|
Greater,
|
||||||
GreaterEqual,
|
GreaterEqual,
|
||||||
|
And,
|
||||||
|
Or,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -134,7 +145,7 @@ enum Instruction {
|
|||||||
Binary(BinaryOp),
|
Binary(BinaryOp),
|
||||||
Call { builtin: Builtin, argc: u8 },
|
Call { builtin: Builtin, argc: u8 },
|
||||||
JumpIfFalse(usize),
|
JumpIfFalse(usize),
|
||||||
JumpIfTrue(usize),
|
ShortCircuit { on: bool, target: usize },
|
||||||
Jump(usize),
|
Jump(usize),
|
||||||
Return,
|
Return,
|
||||||
}
|
}
|
||||||
@@ -165,7 +176,7 @@ impl Program {
|
|||||||
let mut pc = 0usize;
|
let mut pc = 0usize;
|
||||||
while let Some(instruction) = self.instructions.get(pc) {
|
while let Some(instruction) = self.instructions.get(pc) {
|
||||||
match *instruction {
|
match *instruction {
|
||||||
Instruction::Push(value) => scratch.stack.push(value),
|
Instruction::Push(value) => scratch.stack.push(value.normalized()),
|
||||||
Instruction::LoadVariable(index) => {
|
Instruction::LoadVariable(index) => {
|
||||||
let index = usize::from(index);
|
let index = usize::from(index);
|
||||||
let cached = scratch.variables[index];
|
let cached = scratch.variables[index];
|
||||||
@@ -173,7 +184,8 @@ impl Program {
|
|||||||
Some(value) => value,
|
Some(value) => value,
|
||||||
None => {
|
None => {
|
||||||
let expected_type = self.variable_types[index];
|
let expected_type = self.variable_types[index];
|
||||||
let value = resolve(index, &self.variables[index], expected_type)?;
|
let value =
|
||||||
|
resolve(index, &self.variables[index], expected_type)?.normalized();
|
||||||
if value.value_type() != expected_type {
|
if value.value_type() != expected_type {
|
||||||
return Err(EvalError::new(format!(
|
return Err(EvalError::new(format!(
|
||||||
"variable {} expected {:?}, got {:?}",
|
"variable {} expected {:?}, got {:?}",
|
||||||
@@ -219,15 +231,23 @@ impl Program {
|
|||||||
scratch.stack.push(value);
|
scratch.stack.push(value);
|
||||||
}
|
}
|
||||||
Instruction::JumpIfFalse(target) => {
|
Instruction::JumpIfFalse(target) => {
|
||||||
let condition = pop_bool(&mut scratch.stack)?;
|
// Like CASE WHEN, only a known true condition takes this branch.
|
||||||
|
let condition = match pop(&mut scratch.stack)? {
|
||||||
|
Value::Boolean(value) => value,
|
||||||
|
Value::Missing(ValueType::Boolean) => false,
|
||||||
|
_ => return Err(EvalError::new("boolean operand required")),
|
||||||
|
};
|
||||||
if !condition {
|
if !condition {
|
||||||
pc = target;
|
pc = target;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Instruction::JumpIfTrue(target) => {
|
Instruction::ShortCircuit { on, target } => {
|
||||||
let condition = pop_bool(&mut scratch.stack)?;
|
let condition =
|
||||||
if condition {
|
scratch.stack.last().copied().ok_or_else(|| {
|
||||||
|
EvalError::new("stack underflow during short circuit")
|
||||||
|
})?;
|
||||||
|
if condition.as_bool() == Some(on) {
|
||||||
pc = target;
|
pc = target;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -284,43 +304,140 @@ fn pop(stack: &mut Vec<Value>) -> Result<Value, EvalError> {
|
|||||||
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
|
stack.pop().ok_or_else(|| EvalError::new("stack underflow"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, EvalError> {
|
|
||||||
pop(stack)?
|
|
||||||
.as_bool()
|
|
||||||
.ok_or_else(|| EvalError::new("boolean operand required"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn number(value: Value) -> Result<f64, EvalError> {
|
fn number(value: Value) -> Result<f64, EvalError> {
|
||||||
|
if value == Value::Missing(ValueType::Number) {
|
||||||
|
return Ok(f64::NAN);
|
||||||
|
}
|
||||||
value
|
value
|
||||||
.as_number()
|
.as_number()
|
||||||
.ok_or_else(|| EvalError::new("numeric operand required"))
|
.ok_or_else(|| EvalError::new("numeric operand required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
|
fn eval_unary(operator: UnaryOp, value: Value) -> Result<Value, EvalError> {
|
||||||
match operator {
|
if matches!(value, Value::Missing(_)) {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
let result: Result<Value, EvalError> = match operator {
|
||||||
UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
|
UnaryOp::Negate => Ok(Value::Number(-number(value)?)),
|
||||||
UnaryOp::Not => {
|
UnaryOp::Not => {
|
||||||
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
Ok(Value::Boolean(!value.as_bool().ok_or_else(|| {
|
||||||
EvalError::new("boolean operand required for !")
|
EvalError::new("boolean operand required for !")
|
||||||
})?))
|
})?))
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
Ok(result?.normalized())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
|
fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, EvalError> {
|
||||||
match operator {
|
if matches!(operator, BinaryOp::And | BinaryOp::Or) {
|
||||||
|
let (lhs, rhs) = (lhs.as_bool(), rhs.as_bool());
|
||||||
|
let result = match operator {
|
||||||
|
BinaryOp::And if lhs == Some(false) || rhs == Some(false) => Some(false),
|
||||||
|
BinaryOp::And if lhs == Some(true) && rhs == Some(true) => Some(true),
|
||||||
|
BinaryOp::Or if lhs == Some(true) || rhs == Some(true) => Some(true),
|
||||||
|
BinaryOp::Or if lhs == Some(false) && rhs == Some(false) => Some(false),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
return Ok(result
|
||||||
|
.map(Value::Boolean)
|
||||||
|
.unwrap_or(Value::Missing(ValueType::Boolean)));
|
||||||
|
}
|
||||||
|
if matches!(lhs, Value::Missing(_)) || matches!(rhs, Value::Missing(_)) {
|
||||||
|
let value_type = match operator {
|
||||||
|
BinaryOp::Add
|
||||||
|
| BinaryOp::Subtract
|
||||||
|
| BinaryOp::Multiply
|
||||||
|
| BinaryOp::Divide
|
||||||
|
| BinaryOp::Remainder => ValueType::Number,
|
||||||
|
_ => ValueType::Boolean,
|
||||||
|
};
|
||||||
|
return Ok(Value::Missing(value_type));
|
||||||
|
}
|
||||||
|
let result: Result<Value, EvalError> = match operator {
|
||||||
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
|
BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)),
|
||||||
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
|
BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)),
|
||||||
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
|
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
|
||||||
BinaryOp::Divide => 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::Remainder => Ok(Value::Number(number(lhs)? % number(rhs)?)),
|
||||||
BinaryOp::Equal => Ok(Value::Boolean(lhs == rhs)),
|
BinaryOp::Equal => Ok(Value::Boolean(match (lhs, rhs) {
|
||||||
BinaryOp::NotEqual => Ok(Value::Boolean(lhs != rhs)),
|
(Value::Number(lhs), Value::Number(rhs)) => float_equal(lhs, rhs),
|
||||||
BinaryOp::Less => Ok(Value::Boolean(number(lhs)? < number(rhs)?)),
|
(Value::Boolean(lhs), Value::Boolean(rhs)) => lhs == rhs,
|
||||||
BinaryOp::LessEqual => Ok(Value::Boolean(number(lhs)? <= number(rhs)?)),
|
_ => {
|
||||||
BinaryOp::Greater => Ok(Value::Boolean(number(lhs)? > number(rhs)?)),
|
return Err(EvalError::new(
|
||||||
BinaryOp::GreaterEqual => Ok(Value::Boolean(number(lhs)? >= number(rhs)?)),
|
"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> {
|
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"))
|
.ok_or_else(|| EvalError::new("missing builtin argument"))
|
||||||
.and_then(number)
|
.and_then(number)
|
||||||
};
|
};
|
||||||
Ok(match builtin {
|
if !matches!(builtin, Builtin::Nz | Builtin::SafeDiv | Builtin::Iff)
|
||||||
|
&& args.iter().any(|value| matches!(value, Value::Missing(_)))
|
||||||
|
{
|
||||||
|
return Ok(Value::Missing(if builtin == Builtin::Between {
|
||||||
|
ValueType::Boolean
|
||||||
|
} else {
|
||||||
|
ValueType::Number
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let result = match builtin {
|
||||||
Builtin::Round => Value::Number(numeric(0)?.round()),
|
Builtin::Round => Value::Number(numeric(0)?.round()),
|
||||||
Builtin::Floor => Value::Number(numeric(0)?.floor()),
|
Builtin::Floor => Value::Number(numeric(0)?.floor()),
|
||||||
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
|
Builtin::Ceil => Value::Number(numeric(0)?.ceil()),
|
||||||
@@ -341,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
|||||||
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
|
Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)),
|
||||||
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
Builtin::Log => Value::Number(numeric(0)?.ln()),
|
||||||
Builtin::Exp => Value::Number(numeric(0)?.exp()),
|
Builtin::Exp => Value::Number(numeric(0)?.exp()),
|
||||||
Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)),
|
Builtin::Clamp => {
|
||||||
|
let (value, low, high) = (numeric(0)?, numeric(1)?, numeric(2)?);
|
||||||
|
if low > high {
|
||||||
|
return Err(EvalError::new("clamp lower bound exceeds upper bound"));
|
||||||
|
}
|
||||||
|
Value::Number(value.clamp(low, high))
|
||||||
|
}
|
||||||
Builtin::Between => {
|
Builtin::Between => {
|
||||||
let value = numeric(0)?;
|
let value = numeric(0)?;
|
||||||
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
Value::Boolean(value >= numeric(1)? && value <= numeric(2)?)
|
||||||
@@ -365,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
Builtin::Iff => {
|
Builtin::Iff => {
|
||||||
let condition = args
|
let condition = match args.first().copied() {
|
||||||
.first()
|
Some(Value::Boolean(value)) => value,
|
||||||
.and_then(|value| value.as_bool())
|
Some(Value::Missing(ValueType::Boolean)) => false,
|
||||||
.ok_or_else(|| EvalError::new("iff condition must be boolean"))?;
|
_ => return Err(EvalError::new("iff condition must be boolean")),
|
||||||
|
};
|
||||||
if condition { args[1] } else { args[2] }
|
if condition { args[1] } else { args[2] }
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
|
Ok(result.normalized())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -1067,24 +1201,20 @@ where
|
|||||||
let lhs_type = self.expression(lhs)?;
|
let lhs_type = self.expression(lhs)?;
|
||||||
require_type(lhs_type, ValueType::Boolean, position)?;
|
require_type(lhs_type, ValueType::Boolean, position)?;
|
||||||
let branch = self.instructions.len();
|
let branch = self.instructions.len();
|
||||||
self.instructions.push(match operator {
|
self.instructions.push(Instruction::ShortCircuit {
|
||||||
ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX),
|
on: operator == ParsedBinaryOp::Or,
|
||||||
ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX),
|
target: usize::MAX,
|
||||||
_ => unreachable!(),
|
|
||||||
});
|
});
|
||||||
let rhs_type = self.expression(rhs)?;
|
let rhs_type = self.expression(rhs)?;
|
||||||
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
|
require_type(rhs_type, ValueType::Boolean, rhs.position())?;
|
||||||
let end_jump = self.instructions.len();
|
|
||||||
self.instructions.push(Instruction::Jump(usize::MAX));
|
|
||||||
let short_target = self.instructions.len();
|
|
||||||
self.instructions
|
self.instructions
|
||||||
.push(Instruction::Push(Value::Boolean(matches!(
|
.push(Instruction::Binary(if operator == ParsedBinaryOp::And {
|
||||||
operator,
|
BinaryOp::And
|
||||||
ParsedBinaryOp::Or
|
} else {
|
||||||
))));
|
BinaryOp::Or
|
||||||
|
}));
|
||||||
let end_target = self.instructions.len();
|
let end_target = self.instructions.len();
|
||||||
patch_jump(&mut self.instructions, branch, short_target)?;
|
patch_jump(&mut self.instructions, branch, end_target)?;
|
||||||
patch_jump(&mut self.instructions, end_jump, end_target)?;
|
|
||||||
return Ok(ValueType::Boolean);
|
return Ok(ValueType::Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,7 +1363,7 @@ fn patch_jump(
|
|||||||
};
|
};
|
||||||
match instruction {
|
match instruction {
|
||||||
Instruction::JumpIfFalse(value)
|
Instruction::JumpIfFalse(value)
|
||||||
| Instruction::JumpIfTrue(value)
|
| Instruction::ShortCircuit { target: value, .. }
|
||||||
| Instruction::Jump(value) => {
|
| Instruction::Jump(value) => {
|
||||||
*value = target;
|
*value = target;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -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]
|
#[test]
|
||||||
fn short_circuit_does_not_resolve_unused_variable() {
|
fn short_circuit_does_not_resolve_unused_variable() {
|
||||||
let program = compile("false && missing", |name| {
|
let program = compile("false && missing", |name| {
|
||||||
|
|||||||
@@ -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",
|
"factor",
|
||||||
"day_factor",
|
"day_factor",
|
||||||
"rolling_mean",
|
"rolling_mean",
|
||||||
|
"pattern_signal",
|
||||||
|
"pattern_score",
|
||||||
"rolling_mean_current",
|
"rolling_mean_current",
|
||||||
"rolling_max_current",
|
"rolling_max_current",
|
||||||
"rolling_return_stddev_current",
|
"rolling_return_stddev_current",
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|||||||
use chrono::{NaiveDate, NaiveTime};
|
use chrono::{NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use crate::portfolio_loss::PortfolioLossConfig;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||||
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
|
PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||||
RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection,
|
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||||
futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
futures::FuturesDirection, futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyRuntimeSpec {
|
pub struct StrategyRuntimeSpec {
|
||||||
|
#[serde(default)]
|
||||||
|
pub signal_book: Option<crate::signal_contract::SignalBook>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub signal_book_ref: Option<crate::signal_contract::SignalBookReference>,
|
||||||
#[serde(default, alias = "strategy_id")]
|
#[serde(default, alias = "strategy_id")]
|
||||||
pub strategy_id: Option<String>,
|
pub strategy_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -42,6 +47,8 @@ pub struct StrategyRuntimeSpec {
|
|||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
#[serde(default, alias = "factor_value_bindings")]
|
#[serde(default, alias = "factor_value_bindings")]
|
||||||
pub factor_value_bindings: Vec<Value>,
|
pub factor_value_bindings: Vec<Value>,
|
||||||
|
#[serde(default, alias = "stock_pool_factor_contract")]
|
||||||
|
pub stock_pool_factor_contract: Option<Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub parameters: Option<Value>,
|
pub parameters: Option<Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -60,6 +67,8 @@ pub struct StrategyBenchmarkSpec {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyUniverseSpec {
|
pub struct StrategyUniverseSpec {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exclude: Vec<String>,
|
pub exclude: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -639,7 +648,7 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
|
|||||||
/// contract can legitimately arrive with both spellings. Canonicalise those
|
/// contract can legitimately arrive with both spellings. Canonicalise those
|
||||||
/// pairs once at the boundary, while rejecting conflicting values instead of
|
/// pairs once at the boundary, while rejecting conflicting values instead of
|
||||||
/// silently choosing one.
|
/// silently choosing one.
|
||||||
fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
|
pub fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
|
||||||
normalize_strategy_aliases_in_value_inner(value, false)
|
normalize_strategy_aliases_in_value_inner(value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,7 +664,7 @@ fn normalize_strategy_aliases_in_value_inner(
|
|||||||
for (key, child) in object.iter_mut() {
|
for (key, child) in object.iter_mut() {
|
||||||
normalize_strategy_aliases_in_value_inner(
|
normalize_strategy_aliases_in_value_inner(
|
||||||
child,
|
child,
|
||||||
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"),
|
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy" | "automaticTradeProtection" | "automatic_trade_protection"),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,11 +679,14 @@ fn normalize_strategy_aliases_in_value_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
||||||
|
("signalBook", &["signal_book"]),
|
||||||
|
("signalBookRef", &["signal_book_ref"]),
|
||||||
("strategyId", &["strategy_id"]),
|
("strategyId", &["strategy_id"]),
|
||||||
("tradeTimes", &["trade_times"]),
|
("tradeTimes", &["trade_times"]),
|
||||||
("signalSymbol", &["signal_symbol"]),
|
("signalSymbol", &["signal_symbol"]),
|
||||||
("engineConfig", &["engine_config"]),
|
("engineConfig", &["engine_config"]),
|
||||||
("runtimeExpressions", &["runtime_expressions"]),
|
("runtimeExpressions", &["runtime_expressions"]),
|
||||||
|
("automaticTradeProtection", &["automatic_trade_protection"]),
|
||||||
("rebalanceSchedule", &["rebalance_schedule"]),
|
("rebalanceSchedule", &["rebalance_schedule"]),
|
||||||
("skipWindows", &["skip_windows"]),
|
("skipWindows", &["skip_windows"]),
|
||||||
("dynamicRange", &["dynamic_range"]),
|
("dynamicRange", &["dynamic_range"]),
|
||||||
@@ -709,10 +721,8 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
),
|
),
|
||||||
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
|
||||||
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
("stampTaxChangeDate", &["stamp_tax_change_date"]),
|
||||||
("volumeLimit", &["volume_limit"]),
|
("volumeLimit", &["volume_limit", "volumeLimitEnabled", "volume_limit_enabled"]),
|
||||||
("volumeLimitEnabled", &["volume_limit_enabled"]),
|
("liquidityLimit", &["liquidity_limit", "liquidityLimitEnabled", "liquidity_limit_enabled"]),
|
||||||
("liquidityLimit", &["liquidity_limit"]),
|
|
||||||
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
|
|
||||||
("volumePercent", &["volume_percent"]),
|
("volumePercent", &["volume_percent"]),
|
||||||
("riskPolicy", &["risk_policy"]),
|
("riskPolicy", &["risk_policy"]),
|
||||||
("strictValueBudget", &["strict_value_budget"]),
|
("strictValueBudget", &["strict_value_budget"]),
|
||||||
@@ -736,6 +746,16 @@ fn strategy_alias_values_semantically_equal(left: &Value, right: &Value) -> bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
match (left, right) {
|
match (left, right) {
|
||||||
|
(Value::Number(left), Value::Number(right)) => {
|
||||||
|
const MAX_EXACT: i64 = 9_007_199_254_740_992;
|
||||||
|
let exact_integer = |value: &serde_json::Number| {
|
||||||
|
value.as_i64().filter(|v| (-MAX_EXACT..=MAX_EXACT).contains(v)).map(|v| v as f64)
|
||||||
|
.or_else(|| value.as_u64().filter(|v| *v <= MAX_EXACT as u64).map(|v| v as f64))
|
||||||
|
};
|
||||||
|
if left.is_f64() && !right.is_f64() { exact_integer(right).zip(left.as_f64()).is_some_and(|(a,b)| a==b) }
|
||||||
|
else if right.is_f64() && !left.is_f64() { exact_integer(left).zip(right.as_f64()).is_some_and(|(a,b)| a==b) }
|
||||||
|
else { false }
|
||||||
|
}
|
||||||
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
|
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
|
||||||
(Value::String(left), Value::Number(right))
|
(Value::String(left), Value::Number(right))
|
||||||
| (Value::Number(right), Value::String(left)) => left
|
| (Value::Number(right), Value::String(left)) => left
|
||||||
@@ -888,6 +908,12 @@ pub struct StrategyExpressionSelectionConfig {
|
|||||||
pub market_cap_upper_expr: Option<String>,
|
pub market_cap_upper_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stock_filter_expr: Option<String>,
|
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>>,
|
||||||
|
#[serde(default, alias = "preserve_candidate_order")]
|
||||||
|
pub preserve_candidate_order: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
@@ -907,9 +933,13 @@ pub struct StrategyExpressionRiskConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub portfolio_loss_control: Option<StrategyPortfolioLossControlConfig>,
|
||||||
|
#[serde(default)]
|
||||||
pub stop_loss_expr: Option<String>,
|
pub stop_loss_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_profit_expr: Option<String>,
|
pub take_profit_expr: Option<String>,
|
||||||
|
#[serde(default, alias = "position_target_rules")]
|
||||||
|
pub position_target_rules: Vec<StrategyPositionTargetRule>,
|
||||||
#[serde(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
alias = "referencePriceMode",
|
alias = "referencePriceMode",
|
||||||
@@ -918,6 +948,17 @@ pub struct StrategyExpressionRiskConfig {
|
|||||||
pub stop_take_reference_price_mode: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyPositionExposureSchedulePoint {
|
pub struct StrategyPositionExposureSchedulePoint {
|
||||||
@@ -942,6 +983,16 @@ pub struct StrategyPortfolioDrawdownControlConfig {
|
|||||||
pub cooldown_trading_days: Option<usize>,
|
pub cooldown_trading_days: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct StrategyPortfolioLossControlConfig {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub lookback: Option<usize>,
|
||||||
|
pub loss_trigger: Option<f64>,
|
||||||
|
pub floor_exposure: Option<f64>,
|
||||||
|
pub cooldown_trading_days: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionOrderingConfig {
|
pub struct StrategyExpressionOrderingConfig {
|
||||||
@@ -956,6 +1007,10 @@ pub struct StrategyExpressionOrderingConfig {
|
|||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyExpressionTradingConfig {
|
pub struct StrategyExpressionTradingConfig {
|
||||||
|
#[serde(default, alias = "automatic_trade_protection")]
|
||||||
|
pub automatic_trade_protection: Option<crate::holding_policy::AutomaticTradeProtection>,
|
||||||
|
#[serde(default, alias = "buy_filter_expr")]
|
||||||
|
pub buy_filter_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stage: Option<String>,
|
pub stage: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1001,6 +1056,10 @@ pub struct StrategyExpressionTradingConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub release_slot_on_exit_signal: Option<bool>,
|
pub release_slot_on_exit_signal: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub redistribute_target_weights_after_exit: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reenter_exited_targets: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
pub subscription_guard_required: Option<bool>,
|
pub subscription_guard_required: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subscriptions: Vec<String>,
|
pub subscriptions: Vec<String>,
|
||||||
@@ -1236,6 +1295,9 @@ fn parse_stop_take_reference_price_mode(
|
|||||||
"position_cost_basis" | "position_cost" | "execution_cost_basis" => {
|
"position_cost_basis" | "position_cost" | "execution_cost_basis" => {
|
||||||
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
|
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
|
||||||
}
|
}
|
||||||
|
"position_average_entry_price" | "position_avg_price" | "average_entry_price" => {
|
||||||
|
Ok(PlatformStopTakeReferencePriceMode::PositionAverageEntryPrice)
|
||||||
|
}
|
||||||
"signal_day_post_adjusted_close"
|
"signal_day_post_adjusted_close"
|
||||||
| "signal_post_adjusted_close"
|
| "signal_post_adjusted_close"
|
||||||
| "model_signal_post_adjusted_close" => {
|
| "model_signal_post_adjusted_close" => {
|
||||||
@@ -1318,7 +1380,13 @@ fn apply_flat_risk_overrides(
|
|||||||
|
|
||||||
fn sync_quote_quantity_limit(cfg: &mut PlatformExprStrategyConfig) {
|
fn sync_quote_quantity_limit(cfg: &mut PlatformExprStrategyConfig) {
|
||||||
cfg.quote_quantity_limit = cfg.risk_config.trading_constraints.liquidity_limit_enabled
|
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(
|
fn apply_risk_policy_overrides(
|
||||||
@@ -1462,7 +1530,6 @@ fn normalize_slippage_model_name(value: &str) -> String {
|
|||||||
| "price_rate"
|
| "price_rate"
|
||||||
| "price_ratio_slippage"
|
| "price_ratio_slippage"
|
||||||
| "priceratioslippage" => "price_ratio".to_string(),
|
| "priceratioslippage" => "price_ratio".to_string(),
|
||||||
"dynamic_volume_volatility" => "dynamic".to_string(),
|
|
||||||
other => other.to_string(),
|
other => other.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1507,11 +1574,13 @@ fn parse_slippage_model(
|
|||||||
impact_coefficient: Option<f64>,
|
impact_coefficient: Option<f64>,
|
||||||
volatility_coefficient: Option<f64>,
|
volatility_coefficient: Option<f64>,
|
||||||
max_value: Option<f64>,
|
max_value: Option<f64>,
|
||||||
) -> Option<SlippageModel> {
|
) -> Result<SlippageModel, String> {
|
||||||
let value = valid_non_negative(value);
|
for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient),
|
||||||
let impact_coefficient = valid_non_negative(impact_coefficient);
|
("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] {
|
||||||
let volatility_coefficient = valid_non_negative(volatility_coefficient);
|
if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) {
|
||||||
let max_value = valid_non_negative(max_value);
|
return Err(format!("{name} must be finite and non-negative"));
|
||||||
|
}
|
||||||
|
}
|
||||||
let model = model
|
let model = model
|
||||||
.map(normalize_slippage_model_name)
|
.map(normalize_slippage_model_name)
|
||||||
.filter(|item| !item.is_empty())
|
.filter(|item| !item.is_empty())
|
||||||
@@ -1524,16 +1593,23 @@ fn parse_slippage_model(
|
|||||||
});
|
});
|
||||||
|
|
||||||
match model.as_str() {
|
match model.as_str() {
|
||||||
"none" => Some(SlippageModel::None),
|
"none" => Ok(SlippageModel::None),
|
||||||
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
"price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
|
||||||
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
"tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))),
|
||||||
"limit_price" => Some(SlippageModel::LimitPrice),
|
"limit_price" => Ok(SlippageModel::LimitPrice),
|
||||||
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
"historical_volume_volatility" => {
|
||||||
impact_coefficient.unwrap_or(0.5),
|
let max_ratio = max_value.or(value).unwrap_or(0.01);
|
||||||
volatility_coefficient.unwrap_or(0.3),
|
if max_ratio >= 1.0 {
|
||||||
max_value.or(value).unwrap_or(0.01),
|
return Err("historical slippage maximum must be less than 1".into());
|
||||||
))),
|
}
|
||||||
_ => None,
|
Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
|
||||||
|
impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_ratio,
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
"dynamic" | "dynamic_volume_volatility" => Err(
|
||||||
|
"retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into()
|
||||||
|
),
|
||||||
|
_ => Err(format!("unsupported slippageModel: {model}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1564,15 +1640,13 @@ fn apply_execution_behavior_overrides(
|
|||||||
|| slippage_volatility_coefficient.is_some()
|
|| slippage_volatility_coefficient.is_some()
|
||||||
|| slippage_max_value.is_some()
|
|| slippage_max_value.is_some()
|
||||||
{
|
{
|
||||||
if let Some(parsed) = parse_slippage_model(
|
cfg.slippage_model = parse_slippage_model(
|
||||||
slippage_model,
|
slippage_model,
|
||||||
slippage_value,
|
slippage_value,
|
||||||
slippage_impact_coefficient,
|
slippage_impact_coefficient,
|
||||||
slippage_volatility_coefficient,
|
slippage_volatility_coefficient,
|
||||||
slippage_max_value,
|
slippage_max_value,
|
||||||
) {
|
)?;
|
||||||
cfg.slippage_model = parsed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if strict_value_budget == Some(false) {
|
if strict_value_budget == Some(false) {
|
||||||
return Err("strictValueBudget=false is not supported".to_string());
|
return Err("strictValueBudget=false is not supported".to_string());
|
||||||
@@ -1751,6 +1825,23 @@ pub fn platform_expr_config_from_spec(
|
|||||||
let Some(spec) = strategy_spec else {
|
let Some(spec) = strategy_spec else {
|
||||||
return Ok(cfg);
|
return Ok(cfg);
|
||||||
};
|
};
|
||||||
|
if let Some(conditions) = spec.stock_pool_factor_contract.as_ref()
|
||||||
|
.and_then(|contract| contract.get("conditions"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
{
|
||||||
|
for condition in conditions {
|
||||||
|
let Some(binding) = condition.pointer("/semantic/backtestBinding") else { continue };
|
||||||
|
let field = binding.get("field").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let dataset = binding.get("sourceDataset").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if !dataset.starts_with("indicators_") || field.is_empty()
|
||||||
|
|| !field.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||||
|
|| field.as_bytes()[0].is_ascii_digit()
|
||||||
|
{
|
||||||
|
return Err("invalid native factor backtest binding".to_string());
|
||||||
|
}
|
||||||
|
cfg.completed_session_factor_fields.insert(field.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut benchmark_short_explicit = false;
|
let mut benchmark_short_explicit = false;
|
||||||
let mut benchmark_long_explicit = false;
|
let mut benchmark_long_explicit = false;
|
||||||
let mut stock_short_explicit = false;
|
let mut stock_short_explicit = false;
|
||||||
@@ -1912,6 +2003,27 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(universe) = spec.universe.as_ref() {
|
if let Some(universe) = spec.universe.as_ref() {
|
||||||
|
cfg.universe_include = universe
|
||||||
|
.include
|
||||||
|
.as_ref()
|
||||||
|
.map(|raw_symbols| {
|
||||||
|
let mut symbols = BTreeSet::new();
|
||||||
|
for raw_symbol in raw_symbols {
|
||||||
|
let symbol = normalize_symbol(raw_symbol, None);
|
||||||
|
if !is_static_cn_universe_symbol(&symbol) {
|
||||||
|
return Err(format!(
|
||||||
|
"universe.include contains invalid CN stock symbol: {raw_symbol}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !symbols.insert(symbol.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"universe.include contains duplicate normalized symbol: {symbol}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(symbols)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
cfg.universe_exclude = universe
|
cfg.universe_exclude = universe
|
||||||
.exclude
|
.exclude
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2020,6 +2132,42 @@ pub fn platform_expr_config_from_spec(
|
|||||||
{
|
{
|
||||||
cfg.stock_filter_expr = expr.clone();
|
cfg.stock_filter_expr = expr.clone();
|
||||||
}
|
}
|
||||||
|
if let Some(enabled) = selection.current_day_precomputed_factors {
|
||||||
|
cfg.current_day_precomputed_factors = enabled;
|
||||||
|
}
|
||||||
|
if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() {
|
||||||
|
return Err("preserveCandidateOrder requires a dated candidate book".to_string());
|
||||||
|
}
|
||||||
|
for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date {
|
||||||
|
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
|
||||||
|
format!("candidateSymbolsByDate contains invalid date: {raw_date}")
|
||||||
|
})?;
|
||||||
|
let mut symbols = BTreeSet::new();
|
||||||
|
let mut order = BTreeMap::new();
|
||||||
|
for (index, raw_symbol) in raw_symbols.iter().enumerate() {
|
||||||
|
let symbol = normalize_symbol(raw_symbol, None);
|
||||||
|
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
||||||
|
code.len() == 6
|
||||||
|
&& 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}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
order.insert(symbol, index);
|
||||||
|
}
|
||||||
|
cfg.candidate_symbols_by_date.insert(trade_date, symbols);
|
||||||
|
if selection.preserve_candidate_order {
|
||||||
|
cfg.candidate_order_by_date.insert(trade_date, order);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(allocation) = runtime_expr.allocation.as_ref()
|
if let Some(allocation) = runtime_expr.allocation.as_ref()
|
||||||
&& let Some(expr) = allocation
|
&& let Some(expr) = allocation
|
||||||
@@ -2074,6 +2222,18 @@ pub fn platform_expr_config_from_spec(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(control) = risk.portfolio_loss_control.as_ref()
|
||||||
|
&& control.enabled.unwrap_or(true)
|
||||||
|
{
|
||||||
|
let parsed = PortfolioLossConfig {
|
||||||
|
lookback: control.lookback.ok_or("portfolioLossControl.lookback is required")?,
|
||||||
|
loss_trigger: control.loss_trigger.ok_or("portfolioLossControl.lossTrigger is required")?,
|
||||||
|
floor_exposure: control.floor_exposure.ok_or("portfolioLossControl.floorExposure is required")?,
|
||||||
|
cooldown_trading_days: control.cooldown_trading_days.ok_or("portfolioLossControl.cooldownTradingDays is required")?,
|
||||||
|
};
|
||||||
|
parsed.validate().map_err(|error| error.to_string())?;
|
||||||
|
cfg.portfolio_loss_control = Some(parsed);
|
||||||
|
}
|
||||||
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
|
||||||
&& control.enabled.unwrap_or(true)
|
&& control.enabled.unwrap_or(true)
|
||||||
{
|
{
|
||||||
@@ -2125,6 +2285,37 @@ pub fn platform_expr_config_from_spec(
|
|||||||
{
|
{
|
||||||
cfg.take_profit_expr = expr.clone();
|
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
|
if let Some(mode) = risk
|
||||||
.stop_take_reference_price_mode
|
.stop_take_reference_price_mode
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -2158,6 +2349,13 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(trading) = runtime_expr.trading.as_ref() {
|
if let Some(trading) = runtime_expr.trading.as_ref() {
|
||||||
|
if let Some(policy) = &trading.automatic_trade_protection {
|
||||||
|
policy.validate()?;
|
||||||
|
cfg.automatic_trade_protection = policy.clone();
|
||||||
|
}
|
||||||
|
if let Some(expr) = trading.buy_filter_expr.as_ref() {
|
||||||
|
cfg.buy_filter_expr = expr.clone();
|
||||||
|
}
|
||||||
if let Some(expr) = trading
|
if let Some(expr) = trading
|
||||||
.refresh_rate_expr
|
.refresh_rate_expr
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2210,6 +2408,12 @@ pub fn platform_expr_config_from_spec(
|
|||||||
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
if let Some(enabled) = trading.release_slot_on_exit_signal {
|
||||||
cfg.release_slot_on_exit_signal = enabled;
|
cfg.release_slot_on_exit_signal = enabled;
|
||||||
}
|
}
|
||||||
|
if let Some(enabled) = trading.redistribute_target_weights_after_exit {
|
||||||
|
cfg.redistribute_target_weights_after_exit = enabled;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = trading.reenter_exited_targets {
|
||||||
|
cfg.reenter_exited_targets = enabled;
|
||||||
|
}
|
||||||
if let Some(enabled) = trading.delayed_limit_open_exit {
|
if let Some(enabled) = trading.delayed_limit_open_exit {
|
||||||
cfg.delayed_limit_open_exit_enabled = enabled;
|
cfg.delayed_limit_open_exit_enabled = enabled;
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -2371,6 +2575,10 @@ pub fn platform_expr_config_from_spec(
|
|||||||
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
|
||||||
}
|
}
|
||||||
let trade_times = spec_trade_times(spec);
|
let trade_times = spec_trade_times(spec);
|
||||||
|
if crate::pattern_context::specs_in_value(&serde_json::to_value(spec).map_err(|e|e.to_string())?)?.iter().any(|p|p.template=="session_event") {
|
||||||
|
if trade_times.is_empty() {return Err("session_event_requires_explicit_trade_times".into());}
|
||||||
|
cfg.session_event_times=trade_times.clone();
|
||||||
|
}
|
||||||
let explicit_trading_schedule = spec
|
let explicit_trading_schedule = spec
|
||||||
.runtime_expressions
|
.runtime_expressions
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2428,6 +2636,40 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
cfg.strict_value_budget = true;
|
cfg.strict_value_budget = true;
|
||||||
|
|
||||||
|
let signal_book = match (&spec.signal_book,&spec.signal_book_ref) {
|
||||||
|
(Some(_),Some(_)) => return Err("inline_and_registered_signal_book_are_mutually_exclusive".into()),
|
||||||
|
(Some(raw),None) => Some(std::sync::Arc::new(raw.clone().validate()?)),
|
||||||
|
(None,Some(reference)) => crate::signal_contract::cached_signal_book(reference)?,
|
||||||
|
(None,None) => None,
|
||||||
|
};
|
||||||
|
if let Some(book) = signal_book {
|
||||||
|
if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) {
|
||||||
|
return Err("signal_book_requires_one_consume_signal_action".into());
|
||||||
|
}
|
||||||
|
if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() {
|
||||||
|
return Err("signal_book_schedule_does_not_match_strategy".into());
|
||||||
|
}
|
||||||
|
cfg.rotation_enabled = false;
|
||||||
|
cfg.signal_rebalance_dates = book.decision_dates();
|
||||||
|
cfg.initial_subscriptions.extend(book.symbols());
|
||||||
|
cfg.signal_book = Some(book);
|
||||||
|
} else if spec.signal_book_ref.is_some() {
|
||||||
|
if cfg.explicit_actions.len()!=1 || !matches!(cfg.explicit_actions[0],PlatformTradeAction::ConsumeSignal) {
|
||||||
|
return Err("signal_book_requires_one_consume_signal_action".into());
|
||||||
|
}
|
||||||
|
cfg.rotation_enabled=false;
|
||||||
|
} else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) {
|
||||||
|
return Err("consume_signal_requires_verified_signal_book".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_automatic_policy = spec.runtime_expressions.as_ref().and_then(|runtime| runtime.trading.as_ref()).is_some_and(|trading| trading.automatic_trade_protection.is_some());
|
||||||
|
if has_automatic_policy {
|
||||||
|
let limit = i64::from(cfg.automatic_trade_protection.max_holding_days);
|
||||||
|
if cfg.max_holding_days.is_some_and(|previous| previous != limit) {
|
||||||
|
return Err("conflicting maximum holding policies".into());
|
||||||
|
}
|
||||||
|
cfg.max_holding_days = (limit > 0).then_some(limit);
|
||||||
|
}
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2580,6 +2822,7 @@ fn parse_platform_trade_action(
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
match kind.as_str() {
|
match kind.as_str() {
|
||||||
|
"consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal),
|
||||||
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
|
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
|
||||||
target_weights_expr: action
|
target_weights_expr: action
|
||||||
.target_weights_expr
|
.target_weights_expr
|
||||||
@@ -2912,6 +3155,15 @@ fn normalize_symbol(symbol: &str, raw_board: Option<&str>) -> String {
|
|||||||
instrument_query_id(trimmed, &normalize_board(trimmed, raw_board))
|
instrument_query_id(trimmed, &normalize_board(trimmed, raw_board))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_static_cn_universe_symbol(symbol: &str) -> bool {
|
||||||
|
let Some((code, exchange)) = symbol.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
code.len() == 6
|
||||||
|
&& code.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
&& matches!(exchange, "SH" | "SZ" | "BJ")
|
||||||
|
}
|
||||||
|
|
||||||
fn instrument_query_id(symbol: &str, board: &str) -> String {
|
fn instrument_query_id(symbol: &str, board: &str) -> String {
|
||||||
if symbol.contains('.') {
|
if symbol.contains('.') {
|
||||||
return symbol.to_ascii_uppercase();
|
return symbol.to_ascii_uppercase();
|
||||||
@@ -2934,8 +3186,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
||||||
let has_suffix = symbol.trim().rsplit_once('.').is_some();
|
if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) {
|
||||||
if has_suffix && symbol_is_kcb(symbol) {
|
|
||||||
return "KSH".to_string();
|
return "KSH".to_string();
|
||||||
}
|
}
|
||||||
let normalized = raw_board
|
let normalized = raw_board
|
||||||
@@ -2950,9 +3201,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
|||||||
if let Some((_, suffix)) = symbol.rsplit_once('.') {
|
if let Some((_, suffix)) = symbol.rsplit_once('.') {
|
||||||
return suffix.to_ascii_uppercase();
|
return suffix.to_ascii_uppercase();
|
||||||
}
|
}
|
||||||
if symbol_is_kcb(symbol) {
|
|
||||||
return "KSH".to_string();
|
|
||||||
}
|
|
||||||
if symbol.starts_with('8') || symbol.starts_with('4') {
|
if symbol.starts_with('8') || symbol.starts_with('4') {
|
||||||
return "BJ".to_string();
|
return "BJ".to_string();
|
||||||
}
|
}
|
||||||
@@ -2969,27 +3217,56 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
|
|||||||
"UNK".to_string()
|
"UNK".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn symbol_is_kcb(symbol: &str) -> bool {
|
|
||||||
let normalized = symbol.trim().to_ascii_uppercase();
|
|
||||||
let Some((code, suffix)) = normalized.rsplit_once('.') else {
|
|
||||||
return normalized.starts_with("688") || normalized.starts_with("689");
|
|
||||||
};
|
|
||||||
suffix == "SH" && (code.starts_with("688") || code.starts_with("689"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() {
|
fn numeric_strategy_aliases_accept_exact_zero_but_never_hide_rounding_or_conflicts() {
|
||||||
assert_eq!(normalize_board("688001.SH", None), "KSH");
|
let cfg = platform_expr_config_from_value("fees", "000001.SZ", &serde_json::json!({
|
||||||
assert_eq!(normalize_board("689001.SH", None), "KSH");
|
"execution":{"minimumCommission":0.0,"minimum_commission":0}
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(cfg.minimum_commission, Some(0.0));
|
||||||
|
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(9007199254740992u64), &serde_json::json!(9007199254740993u64)));
|
||||||
|
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(0.0), &serde_json::json!(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_buy_filter_as_a_separate_trading_condition() {
|
||||||
|
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
|
||||||
|
"runtimeExpressions": {
|
||||||
|
"selection": {"stockFilterExpr": "close > 0"},
|
||||||
|
"trading": {"buyFilterExpr": "gate > 0"}
|
||||||
|
}
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(cfg.stock_filter_expr, "close > 0");
|
||||||
|
assert_eq!(cfg.buy_filter_expr, "gate > 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_factor_bindings_declare_completed_session_fields() {
|
||||||
|
let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [
|
||||||
|
{"factorRef": "up_days_stock", "semantic": {"backtestBinding": {
|
||||||
|
"field": "ths_up_days_stock", "sourceDataset": "indicators_up_days_stock"
|
||||||
|
}}}
|
||||||
|
]}});
|
||||||
|
let cfg = platform_expr_config_from_value("test", "000852.SH", &spec).unwrap();
|
||||||
|
assert_eq!(cfg.completed_session_factor_fields,
|
||||||
|
BTreeSet::from(["ths_up_days_stock".to_string()]));
|
||||||
|
let empty = platform_expr_config_from_value("test", "000852.SH", &serde_json::json!({})).unwrap();
|
||||||
|
assert!(empty.completed_session_factor_fields.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_board_does_not_infer_kcb_from_security_code() {
|
||||||
|
assert_eq!(normalize_board("688001.SH", None), "SH");
|
||||||
|
assert_eq!(normalize_board("689001.SH", None), "SH");
|
||||||
assert_eq!(normalize_board("688001.BJ", None), "BJ");
|
assert_eq!(normalize_board("688001.BJ", None), "BJ");
|
||||||
assert_eq!(normalize_board("689001.SZ", None), "SZ");
|
assert_eq!(normalize_board("689001.SZ", None), "SZ");
|
||||||
assert_eq!(normalize_board("688001", None), "KSH");
|
assert_eq!(normalize_board("688001", None), "SH");
|
||||||
assert_eq!(normalize_board("688001", Some("SZ")), "SZ");
|
assert_eq!(normalize_board("688001", Some("SZ")), "SZ");
|
||||||
assert_eq!(normalize_board("688001.SH", Some("SH")), "KSH");
|
assert_eq!(normalize_board("688001.SH", Some("SH")), "SH");
|
||||||
|
assert_eq!(normalize_board("000001.SZ", Some("KSH")), "KSH");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2998,14 +3275,22 @@ mod tests {
|
|||||||
"strategyId": "runtime_spec_test",
|
"strategyId": "runtime_spec_test",
|
||||||
"signalSymbol": "000852.SH",
|
"signalSymbol": "000852.SH",
|
||||||
"benchmark": { "instrumentId": "000852.SH" },
|
"benchmark": { "instrumentId": "000852.SH" },
|
||||||
"universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] },
|
"universe": {
|
||||||
|
"include": ["600000.sh", "000001.SZ"],
|
||||||
|
"exclude": ["paused", "st", "kcb", "one_yuan"]
|
||||||
|
},
|
||||||
"runtimeExpressions": {
|
"runtimeExpressions": {
|
||||||
"prelude": "let stocknum = 8;",
|
"prelude": "let stocknum = 8;",
|
||||||
"selection": {
|
"selection": {
|
||||||
"limitExpr": "stocknum",
|
"limitExpr": "stocknum",
|
||||||
"marketCapLowerExpr": "3",
|
"marketCapLowerExpr": "3",
|
||||||
"marketCapUpperExpr": "28",
|
"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": {
|
"trading": {
|
||||||
"refreshRateExpr": "year >= 2024 ? 5 : 20",
|
"refreshRateExpr": "year >= 2024 ? 5 : 20",
|
||||||
@@ -3032,10 +3317,25 @@ mod tests {
|
|||||||
assert_eq!(cfg.signal_symbol, "000852.SH");
|
assert_eq!(cfg.signal_symbol, "000852.SH");
|
||||||
assert_eq!(cfg.selection_limit_expr, "stocknum");
|
assert_eq!(cfg.selection_limit_expr, "stocknum");
|
||||||
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
|
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
|
||||||
|
assert_eq!(
|
||||||
|
cfg.universe_include,
|
||||||
|
Some(BTreeSet::from([
|
||||||
|
"000001.SZ".to_string(),
|
||||||
|
"600000.SH".to_string()
|
||||||
|
]))
|
||||||
|
);
|
||||||
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
|
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
|
||||||
assert!(!cfg.rotation_enabled);
|
assert!(!cfg.rotation_enabled);
|
||||||
assert!(cfg.daily_top_up_enabled);
|
assert!(cfg.daily_top_up_enabled);
|
||||||
assert!(cfg.retry_empty_rebalance);
|
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_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
|
||||||
assert!(!cfg.calendar_rebalance_interval);
|
assert!(!cfg.calendar_rebalance_interval);
|
||||||
assert_eq!(cfg.explicit_actions.len(), 1);
|
assert_eq!(cfg.explicit_actions.len(), 1);
|
||||||
@@ -3045,6 +3345,86 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frozen_candidate_order_is_explicit_and_preserves_source_positions() {
|
||||||
|
let mut spec = serde_json::json!({"runtimeExpressions": {"selection": {
|
||||||
|
"candidateSymbolsByDate": {
|
||||||
|
"2025-01-02": ["600000.SH", "000001.SZ"], "2025-01-03": []
|
||||||
|
}
|
||||||
|
}}});
|
||||||
|
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||||
|
let legacy = platform_expr_config_from_value("", "", &spec).unwrap();
|
||||||
|
assert!(legacy.candidate_order_by_date.is_empty());
|
||||||
|
spec["runtimeExpressions"]["selection"]["preserveCandidateOrder"] = serde_json::json!(true);
|
||||||
|
let ordered = platform_expr_config_from_value("", "", &spec).unwrap();
|
||||||
|
assert_eq!(ordered.candidate_order_by_date[&date]["600000.SH"], 0);
|
||||||
|
assert_eq!(ordered.candidate_order_by_date[&date]["000001.SZ"], 1);
|
||||||
|
assert!(ordered.candidate_order_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty());
|
||||||
|
spec["runtimeExpressions"]["selection"]["candidateSymbolsByDate"] = serde_json::json!({});
|
||||||
|
assert!(platform_expr_config_from_value("", "", &spec).unwrap_err().to_string().contains("dated candidate book"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_or_duplicate_static_universe_symbols() {
|
||||||
|
let invalid = serde_json::json!({
|
||||||
|
"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]
|
#[test]
|
||||||
fn parses_minute_stage_schedule_and_initial_subscriptions() {
|
fn parses_minute_stage_schedule_and_initial_subscriptions() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -3349,7 +3729,9 @@ mod tests {
|
|||||||
"targetPortfolioDaily": true,
|
"targetPortfolioDaily": true,
|
||||||
"rebalanceExistingPositions": true,
|
"rebalanceExistingPositions": true,
|
||||||
"holdUntilExit": true,
|
"holdUntilExit": true,
|
||||||
"releaseSlotOnExitSignal": true
|
"releaseSlotOnExitSignal": true,
|
||||||
|
"redistributeTargetWeightsAfterExit": true,
|
||||||
|
"reenterExitedTargets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3363,6 +3745,8 @@ mod tests {
|
|||||||
assert!(cfg.rebalance_existing_positions);
|
assert!(cfg.rebalance_existing_positions);
|
||||||
assert!(cfg.hold_until_exit_enabled);
|
assert!(cfg.hold_until_exit_enabled);
|
||||||
assert!(cfg.release_slot_on_exit_signal);
|
assert!(cfg.release_slot_on_exit_signal);
|
||||||
|
assert!(cfg.redistribute_target_weights_after_exit);
|
||||||
|
assert!(cfg.reenter_exited_targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3556,6 +3940,24 @@ mod tests {
|
|||||||
assert!(!cfg.quote_quantity_limit);
|
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]
|
#[test]
|
||||||
fn parses_st_and_star_st_risk_policy_switches_into_platform_config() {
|
fn parses_st_and_star_st_risk_policy_switches_into_platform_config() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -3765,6 +4167,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execution_limit_aliases_normalize_to_one_serde_field_without_touching_policy() {
|
||||||
|
for section in ["execution", "engineConfig"] {
|
||||||
|
let mut spec = serde_json::json!({});
|
||||||
|
spec[section] = serde_json::json!({
|
||||||
|
"volumeLimit": false, "volumeLimitEnabled": false, "volume_limit_enabled": false,
|
||||||
|
"liquidityLimit": true, "liquidityLimitEnabled": true, "liquidity_limit_enabled": true,
|
||||||
|
"riskPolicy": {"volumeLimitEnabled": false, "liquidityLimitEnabled": true}
|
||||||
|
});
|
||||||
|
let cfg = platform_expr_config_from_value("test", "000300.SH", &spec).unwrap();
|
||||||
|
assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled);
|
||||||
|
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
|
||||||
|
super::normalize_strategy_aliases_in_value(&mut spec).unwrap();
|
||||||
|
assert!(spec[section].get("volumeLimitEnabled").is_none());
|
||||||
|
assert!(spec[section].get("liquidity_limit_enabled").is_none());
|
||||||
|
assert_eq!(spec[section]["riskPolicy"]["liquidityLimitEnabled"], true);
|
||||||
|
spec[section]["liquidity_limit_enabled"] = serde_json::json!(false);
|
||||||
|
assert!(platform_expr_config_from_value("test", "000300.SH", &spec)
|
||||||
|
.unwrap_err().to_string().contains("conflicting alias values"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
|
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -3948,10 +4372,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_dynamic_slippage_into_platform_config() {
|
fn parses_explicit_historical_slippage_into_platform_config() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
"execution": {
|
"execution": {
|
||||||
"slippageModel": "dynamic",
|
"slippageModel": "historical_volume_volatility",
|
||||||
"slippageImpactCoefficient": 0.6,
|
"slippageImpactCoefficient": 0.6,
|
||||||
"slippageVolatilityCoefficient": 0.2,
|
"slippageVolatilityCoefficient": 0.2,
|
||||||
"slippageMaxValue": 0.015
|
"slippageMaxValue": 0.015
|
||||||
@@ -3962,10 +4386,20 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.slippage_model,
|
cfg.slippage_model,
|
||||||
SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
|
SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retired_or_unknown_slippage_models_do_not_fall_back_to_fixed_or_none() {
|
||||||
|
for model in ["dynamic", "dynamic_volume_volatility", "dynamic-volume-volatility", "unknown"] {
|
||||||
|
let spec = serde_json::json!({"execution": {"slippageModel": model, "slippageValue": 0.002}});
|
||||||
|
assert!(platform_expr_config_from_value("", "", &spec).is_err(), "{model}");
|
||||||
|
}
|
||||||
|
let spec = serde_json::json!({"execution": {"slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": -1}});
|
||||||
|
assert!(platform_expr_config_from_value("", "", &spec).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_stock_ma_filter_generates_price_and_volume_expr() {
|
fn engine_stock_ma_filter_generates_price_and_volume_expr() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -4057,8 +4491,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.position_exposure_schedule
|
cfg.position_exposure_schedule[&NaiveDate::from_ymd_opt(2026, 8, 14).unwrap()],
|
||||||
[&NaiveDate::from_ymd_opt(2026, 8, 14).unwrap()],
|
|
||||||
0.6451,
|
0.6451,
|
||||||
);
|
);
|
||||||
assert_eq!(cfg.position_exposure_schedule.len(), 2);
|
assert_eq!(cfg.position_exposure_schedule.len(), 2);
|
||||||
@@ -4162,6 +4595,20 @@ mod tests {
|
|||||||
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
assert_eq!(cfg.delayed_limit_open_exit_time, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_rotation_keeps_every_declared_clock_not_only_the_last_one() {
|
||||||
|
use crate::Strategy;
|
||||||
|
let literal=serde_json::to_string(&serde_json::json!({"template":"session_event","session_event":"INTRADAY_VOLUME_SPIKE","parameters":{}}).to_string()).unwrap();
|
||||||
|
let mut spec=serde_json::json!({"rebalance":{"tradeTimes":["09:35","10:40","14:59"]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"14:59"},"trading":{"rotationEnabled":true,"buyFilterExpr":format!("pattern_signal({literal})")}},"execution":{"matchingType":"minute_last"}});
|
||||||
|
let config=platform_expr_config_from_value("session","000300.SH",&spec).unwrap();
|
||||||
|
assert_eq!(config.session_event_times.len(),3);
|
||||||
|
let strategy=crate::PlatformExprStrategy::new(config);
|
||||||
|
assert_eq!(strategy.schedule_rules().len(),3);
|
||||||
|
assert_eq!(strategy.decision_quote_times().len(),3);
|
||||||
|
spec["rebalance"]["tradeTimes"]=serde_json::json!([]);
|
||||||
|
assert!(platform_expr_config_from_value("session","000300.SH",&spec).unwrap_err().to_string().contains("explicit_trade_times"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
fn explicit_trading_schedule_overrides_rebalance_trade_times() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -4293,6 +4740,27 @@ mod tests {
|
|||||||
assert_eq!(control.cooldown_trading_days, 30);
|
assert_eq!(control.cooldown_trading_days, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portfolio_loss_contract_is_explicit_and_validated() {
|
||||||
|
let spec = serde_json::json!({"runtimeExpressions":{"risk":{"portfolioLossControl":{
|
||||||
|
"enabled":true,"lookback":20,"lossTrigger":0.05,"floorExposure":0.2,"cooldownTradingDays":10
|
||||||
|
}}}});
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).unwrap();
|
||||||
|
assert_eq!(cfg.portfolio_loss_control.unwrap(), PortfolioLossConfig {
|
||||||
|
lookback:20, loss_trigger:0.05, floor_exposure:0.2, cooldown_trading_days:10,
|
||||||
|
});
|
||||||
|
for (field, value) in [("lookback", serde_json::json!(0)),
|
||||||
|
("lossTrigger", serde_json::json!(0.01)), ("floorExposure", serde_json::json!(1.1)),
|
||||||
|
("cooldownTradingDays", serde_json::json!(0))] {
|
||||||
|
let mut invalid = spec.clone();
|
||||||
|
invalid["runtimeExpressions"]["risk"]["portfolioLossControl"][field] = value;
|
||||||
|
assert!(platform_expr_config_from_value("", "", &invalid).is_err());
|
||||||
|
}
|
||||||
|
let mut missing = spec.clone();
|
||||||
|
missing["runtimeExpressions"]["risk"]["portfolioLossControl"].as_object_mut().unwrap().remove("lossTrigger");
|
||||||
|
assert!(platform_expr_config_from_value("", "", &missing).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_portfolio_drawdown_control() {
|
fn rejects_invalid_portfolio_drawdown_control() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -4331,6 +4799,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_position_average_entry_stop_take_reference_price_mode() {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"runtimeExpressions": {
|
||||||
|
"risk": {
|
||||||
|
"stopTakeReferencePriceMode": "position_average_entry_price"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cfg.stop_take_reference_price_mode,
|
||||||
|
PlatformStopTakeReferencePriceMode::PositionAverageEntryPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_stop_take_reference_price_mode() {
|
fn rejects_unknown_stop_take_reference_price_mode() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ pub struct PositionLot {
|
|||||||
pub struct Position {
|
pub struct Position {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
|
opened_date: Option<NaiveDate>,
|
||||||
|
last_buy_date: Option<NaiveDate>,
|
||||||
|
// ALV-compatible moving average execution price; partial sells do not rebase it.
|
||||||
|
pub average_price: f64,
|
||||||
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
|
||||||
pub average_cost: f64,
|
pub average_cost: f64,
|
||||||
pub last_price: f64,
|
pub last_price: f64,
|
||||||
@@ -86,6 +90,9 @@ impl Position {
|
|||||||
Self {
|
Self {
|
||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
|
opened_date: None,
|
||||||
|
last_buy_date: None,
|
||||||
|
average_price: 0.0,
|
||||||
average_cost: 0.0,
|
average_cost: 0.0,
|
||||||
last_price: 0.0,
|
last_price: 0.0,
|
||||||
realized_pnl: FixedMoney::ZERO,
|
realized_pnl: FixedMoney::ZERO,
|
||||||
@@ -111,6 +118,12 @@ impl Position {
|
|||||||
self.quantity == 0
|
self.quantity == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn opened_date(&self) -> Option<NaiveDate> {
|
||||||
|
self.opened_date
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_buy_date(&self) -> Option<NaiveDate> { self.last_buy_date }
|
||||||
|
|
||||||
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
|
||||||
self.buy_with_mark_price(date, quantity, price, price);
|
self.buy_with_mark_price(date, quantity, price, price);
|
||||||
}
|
}
|
||||||
@@ -127,6 +140,11 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let previous_quantity = self.quantity;
|
let previous_quantity = self.quantity;
|
||||||
|
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
|
||||||
|
if previous_quantity == 0 {
|
||||||
|
self.opened_date = Some(date);
|
||||||
|
}
|
||||||
|
let previous_average_price = self.average_price;
|
||||||
let previous_average_cost = self.average_cost;
|
let previous_average_cost = self.average_cost;
|
||||||
let gross_amount = fixed_money_or_panic(
|
let gross_amount = fixed_money_or_panic(
|
||||||
execution_price * quantity as f64,
|
execution_price * quantity as f64,
|
||||||
@@ -146,6 +164,18 @@ impl Position {
|
|||||||
.day_buy_value
|
.day_buy_value
|
||||||
.checked_add(gross_amount)
|
.checked_add(gross_amount)
|
||||||
.expect("fixed-point day buy value overflow");
|
.expect("fixed-point day buy value overflow");
|
||||||
|
if previous_quantity > 0
|
||||||
|
&& previous_average_price.is_finite()
|
||||||
|
&& previous_average_price > 0.0
|
||||||
|
&& execution_price.is_finite()
|
||||||
|
&& execution_price > 0.0
|
||||||
|
{
|
||||||
|
self.average_price = (previous_average_price * previous_quantity as f64
|
||||||
|
+ execution_price * quantity as f64)
|
||||||
|
/ self.quantity as f64;
|
||||||
|
} else {
|
||||||
|
self.average_price = execution_price;
|
||||||
|
}
|
||||||
if previous_quantity > 0
|
if previous_quantity > 0
|
||||||
&& previous_average_cost.is_finite()
|
&& previous_average_cost.is_finite()
|
||||||
&& previous_average_cost > 0.0
|
&& previous_average_cost > 0.0
|
||||||
@@ -186,6 +216,7 @@ impl Position {
|
|||||||
let mut remaining_proceeds = total_proceeds;
|
let mut remaining_proceeds = total_proceeds;
|
||||||
let mut realized = FixedMoney::ZERO;
|
let mut realized = FixedMoney::ZERO;
|
||||||
let mut realized_entry = FixedMoney::ZERO;
|
let mut realized_entry = FixedMoney::ZERO;
|
||||||
|
let average_price_before_sell = self.average_price;
|
||||||
let average_cost_before_sell = self.average_cost;
|
let average_cost_before_sell = self.average_cost;
|
||||||
|
|
||||||
while remaining > 0 {
|
while remaining > 0 {
|
||||||
@@ -250,11 +281,20 @@ impl Position {
|
|||||||
.checked_add(total_proceeds)
|
.checked_add(total_proceeds)
|
||||||
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
|
||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
|
self.opened_date = None;
|
||||||
|
self.average_price = 0.0;
|
||||||
self.recalculate_average_cost();
|
self.recalculate_average_cost();
|
||||||
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
|
|
||||||
self.average_cost = average_cost_before_sell;
|
|
||||||
} else {
|
} 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();
|
self.refresh_day_pnl();
|
||||||
Ok(realized.to_f64())
|
Ok(realized.to_f64())
|
||||||
@@ -298,6 +338,13 @@ impl Position {
|
|||||||
.to_f64()
|
.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unrealized_average_price_pnl(&self) -> f64 {
|
||||||
|
if self.quantity == 0 || !self.average_price.is_finite() || self.average_price <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
(self.last_price - self.average_price) * self.quantity as f64
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pnl(&self) -> f64 {
|
pub fn pnl(&self) -> f64 {
|
||||||
self.realized_pnl.to_f64() + self.unrealized_pnl()
|
self.realized_pnl.to_f64() + self.unrealized_pnl()
|
||||||
}
|
}
|
||||||
@@ -421,9 +468,12 @@ impl Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
pub fn holding_return(&self, price: f64) -> Option<f64> {
|
||||||
let Some(avg_price) = self.average_entry_price() else {
|
let avg_price = self
|
||||||
return None;
|
.average_price
|
||||||
};
|
.is_finite()
|
||||||
|
.then_some(self.average_price)
|
||||||
|
.filter(|value| *value > 0.0)
|
||||||
|
.or_else(|| self.average_entry_price())?;
|
||||||
if avg_price <= 0.0 {
|
if avg_price <= 0.0 {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -503,6 +553,7 @@ impl Position {
|
|||||||
if adjust_cost_basis {
|
if adjust_cost_basis {
|
||||||
self.average_cost -= dividend_per_share;
|
self.average_cost -= dividend_per_share;
|
||||||
}
|
}
|
||||||
|
self.average_price -= dividend_per_share;
|
||||||
self.last_price -= dividend_per_share;
|
self.last_price -= dividend_per_share;
|
||||||
self.day_dividend_cash = self
|
self.day_dividend_cash = self
|
||||||
.day_dividend_cash
|
.day_dividend_cash
|
||||||
@@ -545,6 +596,11 @@ impl Position {
|
|||||||
self.lots = scaled_lots;
|
self.lots = scaled_lots;
|
||||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||||
self.last_price /= ratio;
|
self.last_price /= ratio;
|
||||||
|
if self.average_price.is_finite() && self.average_price > 0.0 {
|
||||||
|
self.average_price /= ratio;
|
||||||
|
} else {
|
||||||
|
self.average_price = self.average_entry_price().unwrap_or(0.0);
|
||||||
|
}
|
||||||
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
if self.average_cost.is_finite() && self.average_cost > 0.0 {
|
||||||
self.average_cost /= ratio;
|
self.average_cost /= ratio;
|
||||||
} else {
|
} else {
|
||||||
@@ -1006,8 +1062,6 @@ impl PortfolioState {
|
|||||||
let unresolved_delisting = current_market_missing
|
let unresolved_delisting = current_market_missing
|
||||||
&& data.instrument(&position.symbol).is_some_and(|instrument| {
|
&& data.instrument(&position.symbol).is_some_and(|instrument| {
|
||||||
instrument.is_delisted_on_or_before(date)
|
instrument.is_delisted_on_or_before(date)
|
||||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
|
||||||
&& instrument.delisted_at.is_none())
|
|
||||||
});
|
});
|
||||||
if unresolved_delisting {
|
if unresolved_delisting {
|
||||||
position.last_price = 0.0;
|
position.last_price = 0.0;
|
||||||
@@ -1027,11 +1081,13 @@ impl PortfolioState {
|
|||||||
position.refresh_day_pnl();
|
position.refresh_day_pnl();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|
||||||
|
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
|
||||||
let price = data
|
let price = data
|
||||||
.price(date, &position.symbol, field)
|
.price(date, &position.symbol, field)
|
||||||
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
|
.or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
(position.last_price.is_finite() && position.last_price > 0.0)
|
(confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
|
||||||
.then_some(position.last_price)
|
.then_some(position.last_price)
|
||||||
})
|
})
|
||||||
.ok_or_else(|| DataSetError::MissingSnapshot {
|
.ok_or_else(|| DataSetError::MissingSnapshot {
|
||||||
@@ -1128,8 +1184,11 @@ impl PortfolioState {
|
|||||||
.map(|position| {
|
.map(|position| {
|
||||||
let market_value = position.market_value();
|
let market_value = position.market_value();
|
||||||
let entry_average_cost = position
|
let entry_average_cost = position
|
||||||
.average_entry_price()
|
.average_price
|
||||||
|
.is_finite()
|
||||||
|
.then_some(position.average_price)
|
||||||
.filter(|value| value.is_finite() && *value > 0.0)
|
.filter(|value| value.is_finite() && *value > 0.0)
|
||||||
|
.or_else(|| position.average_entry_price())
|
||||||
.unwrap_or(position.average_cost);
|
.unwrap_or(position.average_cost);
|
||||||
HoldingSummary {
|
HoldingSummary {
|
||||||
date,
|
date,
|
||||||
@@ -1143,7 +1202,7 @@ impl PortfolioState {
|
|||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
},
|
},
|
||||||
unrealized_pnl: position.unrealized_entry_pnl(),
|
unrealized_pnl: position.unrealized_average_price_pnl(),
|
||||||
realized_pnl: position.realized_entry_pnl(),
|
realized_pnl: position.realized_entry_pnl(),
|
||||||
pnl: position.entry_pnl(),
|
pnl: position.entry_pnl(),
|
||||||
trading_pnl: position.trading_pnl,
|
trading_pnl: position.trading_pnl,
|
||||||
@@ -1180,7 +1239,10 @@ impl PortfolioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let old_quantity = old_position.quantity;
|
let old_quantity = old_position.quantity;
|
||||||
|
let old_opened_date = old_position.opened_date;
|
||||||
|
let old_last_buy_date = old_position.last_buy_date;
|
||||||
let last_price = old_position.last_price;
|
let last_price = old_position.last_price;
|
||||||
|
let old_average_price = old_position.average_price;
|
||||||
let old_average_cost = old_position.average_cost;
|
let old_average_cost = old_position.average_cost;
|
||||||
let realized_pnl = old_position.realized_pnl;
|
let realized_pnl = old_position.realized_pnl;
|
||||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||||
@@ -1218,6 +1280,15 @@ impl PortfolioState {
|
|||||||
.entry(new_symbol.to_string())
|
.entry(new_symbol.to_string())
|
||||||
.or_insert_with(|| Position::new(new_symbol));
|
.or_insert_with(|| Position::new(new_symbol));
|
||||||
let successor_quantity_before = successor.quantity;
|
let successor_quantity_before = successor.quantity;
|
||||||
|
successor.opened_date = match (successor.opened_date, old_opened_date) {
|
||||||
|
(Some(current), Some(previous)) => Some(current.min(previous)),
|
||||||
|
(current, previous) => current.or(previous),
|
||||||
|
};
|
||||||
|
successor.last_buy_date = match (successor.last_buy_date, old_last_buy_date) {
|
||||||
|
(Some(current), Some(previous)) => Some(current.max(previous)),
|
||||||
|
(current, previous) => current.or(previous),
|
||||||
|
};
|
||||||
|
let successor_average_price_before = successor.average_price;
|
||||||
let successor_average_cost_before = successor.average_cost;
|
let successor_average_cost_before = successor.average_cost;
|
||||||
successor.lots.extend(converted_lots);
|
successor.lots.extend(converted_lots);
|
||||||
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||||
@@ -1232,6 +1303,30 @@ impl PortfolioState {
|
|||||||
if converted_last_price > 0.0 {
|
if converted_last_price > 0.0 {
|
||||||
successor.last_price = converted_last_price;
|
successor.last_price = converted_last_price;
|
||||||
}
|
}
|
||||||
|
let converted_average_price = if old_average_price.is_finite()
|
||||||
|
&& old_average_price > 0.0
|
||||||
|
&& ratio.is_finite()
|
||||||
|
&& ratio > 0.0
|
||||||
|
{
|
||||||
|
Some(old_average_price / ratio)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(converted_average_price) = converted_average_price {
|
||||||
|
if successor_quantity_before > 0
|
||||||
|
&& successor_average_price_before.is_finite()
|
||||||
|
&& successor_average_price_before > 0.0
|
||||||
|
{
|
||||||
|
successor.average_price = (successor_average_price_before
|
||||||
|
* successor_quantity_before as f64
|
||||||
|
+ converted_average_price * converted_quantity as f64)
|
||||||
|
/ successor.quantity as f64;
|
||||||
|
} else {
|
||||||
|
successor.average_price = converted_average_price;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
successor.average_price = successor.average_entry_price().unwrap_or(0.0);
|
||||||
|
}
|
||||||
let converted_average_cost = if old_average_cost.is_finite()
|
let converted_average_cost = if old_average_cost.is_finite()
|
||||||
&& old_average_cost > 0.0
|
&& old_average_cost > 0.0
|
||||||
&& ratio.is_finite()
|
&& ratio.is_finite()
|
||||||
@@ -1375,8 +1470,10 @@ mod tests {
|
|||||||
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
|
let realized = position.sell(100, 6.0).expect("partial FIFO sell");
|
||||||
|
|
||||||
assert_eq!(position.quantity, 100);
|
assert_eq!(position.quantity, 100);
|
||||||
|
assert!((position.average_price - 7.5).abs() < 1e-12);
|
||||||
assert!((position.average_cost - 7.55).abs() < 1e-12);
|
assert!((position.average_cost - 7.55).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||||
|
assert!((position.unrealized_average_price_pnl() + 150.0).abs() < 1e-12);
|
||||||
assert!((realized + 405.0).abs() < 1e-12);
|
assert!((realized + 405.0).abs() < 1e-12);
|
||||||
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
|
assert!((position.unrealized_pnl() - 95.0).abs() < 1e-12);
|
||||||
assert!((position.pnl() + 310.0).abs() < 1e-12);
|
assert!((position.pnl() + 310.0).abs() < 1e-12);
|
||||||
@@ -1409,6 +1506,7 @@ mod tests {
|
|||||||
position.record_buy_trade_cost(22_200, 100.0);
|
position.record_buy_trade_cost(22_200, 100.0);
|
||||||
|
|
||||||
assert!(position.average_cost > 5.66);
|
assert!(position.average_cost > 5.66);
|
||||||
|
assert!((position.average_price - 5.66).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
|
||||||
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1528,7 @@ mod tests {
|
|||||||
position.sell(2700, 16.8331).expect("partial sell");
|
position.sell(2700, 16.8331).expect("partial sell");
|
||||||
|
|
||||||
assert_eq!(position.quantity, 100);
|
assert_eq!(position.quantity, 100);
|
||||||
|
assert!((position.average_price - 18.94711428571429).abs() < 1e-12);
|
||||||
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,11 +1542,13 @@ mod tests {
|
|||||||
|
|
||||||
position.sell(100, 6.0).expect("partial sell");
|
position.sell(100, 6.0).expect("partial sell");
|
||||||
assert_eq!(position.quantity, 100);
|
assert_eq!(position.quantity, 100);
|
||||||
|
assert!((position.average_price - 7.5).abs() < 1e-12);
|
||||||
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
assert!((position.average_cost - 7.5).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||||
|
|
||||||
position.buy(date, 100, 5.0);
|
position.buy(date, 100, 5.0);
|
||||||
assert_eq!(position.quantity, 200);
|
assert_eq!(position.quantity, 200);
|
||||||
|
assert!((position.average_price - 6.25).abs() < 1e-12);
|
||||||
assert!((position.average_cost - 6.25).abs() < 1e-12);
|
assert!((position.average_cost - 6.25).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
@@ -1489,6 +1590,7 @@ mod tests {
|
|||||||
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
|
let cash = position.apply_cash_dividend_preserve_cost_basis(0.6);
|
||||||
|
|
||||||
assert!((cash - 600.0).abs() < 1e-12);
|
assert!((cash - 600.0).abs() < 1e-12);
|
||||||
|
assert!((position.average_price - 45.85).abs() < 1e-12);
|
||||||
assert!((position.average_cost - cost_before).abs() < 1e-12);
|
assert!((position.average_cost - cost_before).abs() < 1e-12);
|
||||||
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
|
assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12);
|
||||||
assert!((position.last_price - 45.85).abs() < 1e-12);
|
assert!((position.last_price - 45.85).abs() < 1e-12);
|
||||||
@@ -1571,6 +1673,7 @@ mod tests {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1659,6 +1762,7 @@ mod tests {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1695,7 +1799,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
|
fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
|
||||||
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
|
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
|
||||||
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
|
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
|
||||||
let mut portfolio = PortfolioState::new(10_000.0);
|
let mut portfolio = PortfolioState::new(10_000.0);
|
||||||
@@ -1753,9 +1857,23 @@ mod tests {
|
|||||||
.update_prices(prev_date, &dataset, PriceField::Close)
|
.update_prices(prev_date, &dataset, PriceField::Close)
|
||||||
.expect("previous close");
|
.expect("previous close");
|
||||||
portfolio.begin_trading_day();
|
portfolio.begin_trading_day();
|
||||||
portfolio
|
let error = portfolio
|
||||||
.update_prices(missing_date, &dataset, PriceField::Close)
|
.update_prices(missing_date, &dataset, PriceField::Close)
|
||||||
.expect("missing current row should carry previous close");
|
.expect_err("unclassified missing current price must not be filled from history");
|
||||||
|
assert!(error.to_string().contains("601028.SH"));
|
||||||
|
let paused_dataset = DataSet::from_components(
|
||||||
|
vec![dataset.instrument("601028.SH").unwrap().clone()],
|
||||||
|
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
|
||||||
|
Vec::new(),
|
||||||
|
vec![crate::data::CandidateEligibility {
|
||||||
|
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
|
||||||
|
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
|
||||||
|
is_kcb: false, is_one_yuan: false, risk_level_code: None,
|
||||||
|
}],
|
||||||
|
vec![dataset.benchmark(prev_date).unwrap().clone()],
|
||||||
|
).unwrap();
|
||||||
|
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
|
||||||
|
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
|
||||||
|
|
||||||
let position = portfolio.position("601028.SH").expect("position");
|
let position = portfolio.position("601028.SH").expect("position");
|
||||||
assert!((position.last_price - 10.3).abs() < 1e-6);
|
assert!((position.last_price - 10.3).abs() < 1e-6);
|
||||||
|
|||||||
@@ -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,10 +3,10 @@ use std::collections::BTreeSet;
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::OrderSide;
|
||||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::portfolio::Position;
|
use crate::portfolio::Position;
|
||||||
use crate::OrderSide;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct ChinaAShareRiskControl;
|
pub struct ChinaAShareRiskControl;
|
||||||
@@ -138,6 +138,16 @@ pub struct FidcRiskDecisionAudit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FidcRiskDecisionAudit {
|
impl FidcRiskDecisionAudit {
|
||||||
|
pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
date, symbol: symbol.into(), scope: RiskCheckScope::Buy,
|
||||||
|
stage: "buy_planning".into(), accepted: false,
|
||||||
|
rule_code: reason.into(), reason: reason.into(),
|
||||||
|
config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(),
|
||||||
|
selection_batch_id: None, order_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rejected_selection(
|
pub fn rejected_selection(
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
symbol: impl Into<String>,
|
symbol: impl Into<String>,
|
||||||
@@ -208,14 +218,8 @@ impl ChinaAShareRiskControl {
|
|||||||
{
|
{
|
||||||
return Some("inactive_or_delisted");
|
return Some("inactive_or_delisted");
|
||||||
}
|
}
|
||||||
let status = instrument.status.trim().to_ascii_lowercase();
|
// Latest reference status has no historical as-of date. Execution-day
|
||||||
let terminal_status = matches!(
|
// risk snapshots remain authoritative; missing quotes are not waived.
|
||||||
status.as_str(),
|
|
||||||
"inactive" | "delisted" | "terminated" | "expired"
|
|
||||||
);
|
|
||||||
if terminal_status && instrument.delisted_at.is_none() {
|
|
||||||
return Some("inactive_or_delisted");
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +401,7 @@ impl ChinaAShareRiskControl {
|
|||||||
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
|
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
};
|
};
|
||||||
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) {
|
if reject_kcb && candidate.is_kcb {
|
||||||
return Some("kcb");
|
return Some("kcb");
|
||||||
}
|
}
|
||||||
let reject_bjse = match scope {
|
let reject_bjse = match scope {
|
||||||
@@ -410,10 +414,13 @@ impl ChinaAShareRiskControl {
|
|||||||
}
|
}
|
||||||
let reject_one_yuan = match scope {
|
let reject_one_yuan = match scope {
|
||||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
RiskCheckScope::Buy => false,
|
||||||
RiskCheckScope::Sell => false,
|
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");
|
return Some("one_yuan");
|
||||||
}
|
}
|
||||||
if Self::missing_risk_state_rejected(candidate, config, scope) {
|
if Self::missing_risk_state_rejected(candidate, config, scope) {
|
||||||
@@ -480,6 +487,14 @@ impl ChinaAShareRiskControl {
|
|||||||
) {
|
) {
|
||||||
return Some(reason);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
|
if !check_price.is_finite() || check_price <= 0.0 {
|
||||||
|
return Some("invalid execution price");
|
||||||
|
}
|
||||||
|
// Daily candidate flags can describe the later close. Execution
|
||||||
|
// price constraints must use this order's actual pricing clock.
|
||||||
|
if config.static_rules.reject_one_yuan_buy && check_price <= 1.0 {
|
||||||
|
return Some("one_yuan");
|
||||||
|
}
|
||||||
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
|
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
|
||||||
return Some("buy_disabled");
|
return Some("buy_disabled");
|
||||||
}
|
}
|
||||||
@@ -560,7 +575,9 @@ impl ChinaAShareRiskControl {
|
|||||||
// lifecycle fact must still protect the sell path. Otherwise a
|
// lifecycle fact must still protect the sell path. Otherwise a
|
||||||
// `inactive_or_delisted` candidate could fall through to a synthetic
|
// `inactive_or_delisted` candidate could fall through to a synthetic
|
||||||
// sell price and violate the unresolved-delisted holding contract.
|
// 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);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
|
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
|
||||||
@@ -595,11 +612,6 @@ impl ChinaAShareRiskControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn symbol_is_kcb(symbol: &str) -> bool {
|
|
||||||
let normalized = symbol.trim().to_ascii_uppercase();
|
|
||||||
(normalized.starts_with("688") || normalized.starts_with("689")) && normalized.ends_with(".SH")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn symbol_is_bjse(symbol: &str) -> bool {
|
fn symbol_is_bjse(symbol: &str) -> bool {
|
||||||
let normalized = symbol.trim().to_ascii_uppercase();
|
let normalized = symbol.trim().to_ascii_uppercase();
|
||||||
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
|
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
|
||||||
@@ -664,7 +676,6 @@ fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -
|
|||||||
|| config.static_rules.reject_new_listing_buy
|
|| config.static_rules.reject_new_listing_buy
|
||||||
|| config.static_rules.reject_kcb_buy
|
|| config.static_rules.reject_kcb_buy
|
||||||
|| config.static_rules.reject_bjse_buy
|
|| config.static_rules.reject_bjse_buy
|
||||||
|| config.static_rules.reject_one_yuan_buy
|
|
||||||
|| config.static_rules.reject_upper_limit_buy
|
|| config.static_rules.reject_upper_limit_buy
|
||||||
|| config.static_rules.respect_allow_buy_sell;
|
|| config.static_rules.respect_allow_buy_sell;
|
||||||
}
|
}
|
||||||
@@ -741,7 +752,7 @@ fn missing_single_field_rejected(
|
|||||||
},
|
},
|
||||||
"is_one_yuan" | "one_yuan" => match scope {
|
"is_one_yuan" | "one_yuan" => match scope {
|
||||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
RiskCheckScope::Buy => false,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
},
|
},
|
||||||
"allow_buy" => match scope {
|
"allow_buy" => match scope {
|
||||||
@@ -785,7 +796,6 @@ fn missing_single_field_rejected(
|
|||||||
|| config.static_rules.reject_new_listing_buy
|
|| config.static_rules.reject_new_listing_buy
|
||||||
|| config.static_rules.reject_kcb_buy
|
|| config.static_rules.reject_kcb_buy
|
||||||
|| config.static_rules.reject_bjse_buy
|
|| config.static_rules.reject_bjse_buy
|
||||||
|| config.static_rules.reject_one_yuan_buy
|
|
||||||
|| config.static_rules.reject_upper_limit_buy
|
|| config.static_rules.reject_upper_limit_buy
|
||||||
|| config.static_rules.respect_allow_buy_sell
|
|| config.static_rules.respect_allow_buy_sell
|
||||||
}
|
}
|
||||||
@@ -843,7 +853,7 @@ mod tests {
|
|||||||
Some(&instrument("delisted", None)),
|
Some(&instrument("delisted", None)),
|
||||||
date,
|
date,
|
||||||
),
|
),
|
||||||
Some("inactive_or_delisted")
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ChinaAShareRiskControl::instrument_rejection_reason(
|
ChinaAShareRiskControl::instrument_rejection_reason(
|
||||||
@@ -902,6 +912,61 @@ mod tests {
|
|||||||
position
|
position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
|
||||||
|
let day = d(2025, 2, 6);
|
||||||
|
let mut candidate = candidate(day);
|
||||||
|
let mut snapshot = market(day, 1.2, 0.5);
|
||||||
|
let config = FidcRiskControlConfig::default();
|
||||||
|
candidate.is_one_yuan = true;
|
||||||
|
snapshot.day_open = 0.9;
|
||||||
|
snapshot.close = 0.8;
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 1.2, &config), None);
|
||||||
|
candidate.is_one_yuan = false;
|
||||||
|
snapshot.day_open = 1.2;
|
||||||
|
snapshot.close = 1.3;
|
||||||
|
for price in [0.9, 1.0] {
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, price, &config), Some("one_yuan"));
|
||||||
|
}
|
||||||
|
let mut relaxed = config;
|
||||||
|
relaxed.static_rules.reject_one_yuan_buy = false;
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
|
||||||
|
let day = d(2025, 2, 6);
|
||||||
|
let mut candidate = candidate(day);
|
||||||
|
let snapshot = market(day, 1.2, 0.5);
|
||||||
|
let config = FidcRiskControlConfig::default();
|
||||||
|
candidate.risk_level_code = Some("missing_risk_state:is_one_yuan".into());
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 1.2, &config), None);
|
||||||
|
candidate.risk_level_code = Some("missing_risk_state:is_st".into());
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, 1.2, &config), Some("missing_risk_state"));
|
||||||
|
candidate.risk_level_code = None;
|
||||||
|
for price in [0.0, f64::NAN, f64::INFINITY] {
|
||||||
|
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, price, &config), Some("invalid execution price"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_one_yuan_selection_policy_still_uses_selection_facts() {
|
||||||
|
let day = d(2025, 2, 6);
|
||||||
|
let mut candidate = candidate(day);
|
||||||
|
candidate.is_one_yuan = true;
|
||||||
|
let snapshot = market(day, 1.2, 0.5);
|
||||||
|
let mut config = FidcRiskControlConfig::default();
|
||||||
|
config.static_rules.reject_one_yuan_selection = true;
|
||||||
|
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||||
|
day, &candidate, &snapshot, None, &config), Some("one_yuan"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
||||||
let prev_date = d(2024, 4, 16);
|
let prev_date = d(2024, 4, 16);
|
||||||
@@ -1004,6 +1069,24 @@ mod tests {
|
|||||||
assert_eq!(configured_reason, None);
|
assert_eq!(configured_reason, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kcb_filter_uses_classification_instead_of_security_code() {
|
||||||
|
let date = d(2025, 1, 2);
|
||||||
|
let market = market(date, 6.27, 5.63);
|
||||||
|
let mut candidate = candidate(date);
|
||||||
|
let config = FidcRiskControlConfig::default();
|
||||||
|
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
|
||||||
|
candidate.symbol = symbol.to_string();
|
||||||
|
for is_kcb in [false, true] {
|
||||||
|
candidate.is_kcb = is_kcb;
|
||||||
|
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||||
|
date, &candidate, &market, None, 6.27, &config,
|
||||||
|
);
|
||||||
|
assert_eq!(reason, is_kcb.then_some("kcb"), "{symbol}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn st_and_star_st_filters_are_independent() {
|
fn st_and_star_st_filters_are_independent() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
@@ -1134,6 +1217,7 @@ mod tests {
|
|||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
let mut candidate = candidate(date);
|
let mut candidate = candidate(date);
|
||||||
candidate.symbol = "688506.SH".to_string();
|
candidate.symbol = "688506.SH".to_string();
|
||||||
|
candidate.is_kcb = true;
|
||||||
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
candidate.risk_level_code = Some("missing_risk_state".to_string());
|
||||||
let market = market(date, 6.27, 5.63);
|
let market = market(date, 6.27, 5.63);
|
||||||
let mut config = FidcRiskControlConfig::default();
|
let mut config = FidcRiskControlConfig::default();
|
||||||
|
|||||||
@@ -284,18 +284,27 @@ mod tests {
|
|||||||
|
|
||||||
assert!(scheduler.is_due_on(d(2025, 1, 30), &daily));
|
assert!(scheduler.is_due_on(d(2025, 1, 30), &daily));
|
||||||
assert!(scheduler.is_due_on(d(2025, 1, 31), &daily));
|
assert!(scheduler.is_due_on(d(2025, 1, 31), &daily));
|
||||||
assert!(scheduler.triggered_rules_at(
|
assert!(
|
||||||
d(2025, 1, 30),
|
scheduler
|
||||||
ScheduleStage::OnDay,
|
.triggered_rules_at(
|
||||||
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()),
|
d(2025, 1, 30),
|
||||||
std::slice::from_ref(&daily),
|
ScheduleStage::OnDay,
|
||||||
).len() == 1);
|
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()),
|
||||||
assert!(scheduler.triggered_rules_at(
|
std::slice::from_ref(&daily),
|
||||||
d(2025, 1, 30),
|
)
|
||||||
ScheduleStage::OnDay,
|
.len()
|
||||||
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
|
== 1
|
||||||
std::slice::from_ref(&daily),
|
);
|
||||||
).is_empty());
|
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]
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
//! Immutable, account-independent trading signals. Quantity and execution
|
||||||
|
//! prices are intentionally absent; the existing broker owns those decisions.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||||
|
|
||||||
|
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::strategy::{OrderIntent, StrategyContext};
|
||||||
|
use crate::portfolio::PortfolioState;
|
||||||
|
|
||||||
|
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SignalBookReference {
|
||||||
|
pub book_id: String,
|
||||||
|
pub version_sha256: String,
|
||||||
|
pub artifact_sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalBookReference {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256)
|
||||||
|
|| self.book_id != format!("signal_book_{}",self.version_sha256)
|
||||||
|
{ return Err("signal_book_reference_invalid".into()); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SignalCache {
|
||||||
|
entries: BTreeMap<String,Weak<ValidatedSignalBook>>,
|
||||||
|
retained: std::collections::VecDeque<(String,Arc<ValidatedSignalBook>,usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_cache() -> &'static Mutex<SignalCache> {
|
||||||
|
static CACHE: OnceLock<Mutex<SignalCache>> = OnceLock::new();
|
||||||
|
CACHE.get_or_init(||Mutex::new(SignalCache::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cached_signal_book(reference: &SignalBookReference) -> Result<Option<Arc<ValidatedSignalBook>>,String> {
|
||||||
|
reference.validate()?;
|
||||||
|
let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
||||||
|
let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade);
|
||||||
|
if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) {
|
||||||
|
return Err("signal_book_cached_version_mismatch".into());
|
||||||
|
}
|
||||||
|
Ok(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result<Arc<ValidatedSignalBook>,String> {
|
||||||
|
use sha2::{Digest,Sha256};
|
||||||
|
reference.validate()?;
|
||||||
|
if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 {
|
||||||
|
return Err("signal_book_artifact_hash_or_size_invalid".into());
|
||||||
|
}
|
||||||
|
let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?;
|
||||||
|
if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); }
|
||||||
|
let book=Arc::new(raw.validate()?);
|
||||||
|
let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
|
||||||
|
cache.entries.retain(|_,value|value.strong_count()>0);
|
||||||
|
if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); }
|
||||||
|
cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book));
|
||||||
|
let estimated=body.len().saturating_mul(4);
|
||||||
|
if estimated<=128*1024*1024 {
|
||||||
|
cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated));
|
||||||
|
while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::<usize>()>128*1024*1024 {
|
||||||
|
cache.retained.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(book)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SignalProvenance {
|
||||||
|
Observed,
|
||||||
|
Reconstructed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SignalFrequency {
|
||||||
|
Daily,
|
||||||
|
Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum SignalAction {
|
||||||
|
TargetWeight { symbol: String, weight: f64 },
|
||||||
|
BuyCondition { symbol: String, allowed: bool },
|
||||||
|
Exit { symbol: String },
|
||||||
|
Reduce { symbol: String, remaining_ratio: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalAction {
|
||||||
|
fn symbol(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::TargetWeight { symbol, .. }
|
||||||
|
| Self::BuyCondition { symbol, .. }
|
||||||
|
| Self::Exit { symbol }
|
||||||
|
| Self::Reduce { symbol, .. } => symbol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SignalSnapshot {
|
||||||
|
pub signal_at: DateTime<Utc>,
|
||||||
|
pub decision_at: DateTime<Utc>,
|
||||||
|
pub input_as_of: DateTime<Utc>,
|
||||||
|
pub input_available_at: DateTime<Utc>,
|
||||||
|
pub generated_at: DateTime<Utc>,
|
||||||
|
pub published_at: DateTime<Utc>,
|
||||||
|
pub input_sha256: String,
|
||||||
|
pub complete_targets: bool,
|
||||||
|
pub actions: Vec<SignalAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SignalBook {
|
||||||
|
pub schema: String,
|
||||||
|
pub version_sha256: String,
|
||||||
|
pub generator_sha256: String,
|
||||||
|
pub model_sha256: Option<String>,
|
||||||
|
pub knowledge_cutoff: Option<DateTime<Utc>>,
|
||||||
|
pub provenance: SignalProvenance,
|
||||||
|
pub frequency: SignalFrequency,
|
||||||
|
pub expected_decisions: Vec<DateTime<Utc>>,
|
||||||
|
pub snapshots: Vec<SignalSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ValidatedSignalBook {
|
||||||
|
book: SignalBook,
|
||||||
|
index: BTreeMap<NaiveDateTime, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_sha(value: &str) -> bool {
|
||||||
|
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
|
||||||
|
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalBook {
|
||||||
|
pub fn content_sha256(&self) -> Result<String, String> {
|
||||||
|
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
|
||||||
|
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
|
||||||
|
value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null);
|
||||||
|
value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::<Vec<_>>());
|
||||||
|
for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) {
|
||||||
|
let object=raw.as_object_mut().ok_or("signal_snapshot_required")?;
|
||||||
|
object.remove("generatedAt");
|
||||||
|
object.remove("publishedAt");
|
||||||
|
for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at),
|
||||||
|
("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] {
|
||||||
|
object.insert(key.into(),serde_json::json!(at.timestamp_micros()));
|
||||||
|
}
|
||||||
|
for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) {
|
||||||
|
match action {
|
||||||
|
SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())),
|
||||||
|
SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())),
|
||||||
|
_=>{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn sorted(value:serde_json::Value)->serde_json::Value {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value)))
|
||||||
|
.collect::<BTreeMap<_,_>>().into_iter().collect()),
|
||||||
|
serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()),
|
||||||
|
other=>other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let raw=serde_json::to_vec(&sorted(value)).map_err(|error|error.to_string())?;
|
||||||
|
Ok(format!("{:x}",Sha256::digest(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
|
||||||
|
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|
||||||
|
|| !valid_sha(&self.generator_sha256)
|
||||||
|
{
|
||||||
|
return Err("signal_book_identity_invalid".into());
|
||||||
|
}
|
||||||
|
if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value))
|
||||||
|
|| self.model_sha256.is_some() != self.knowledge_cutoff.is_some()
|
||||||
|
{ return Err("signal_model_training_identity_incomplete".into()); }
|
||||||
|
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|
||||||
|
|| self.expected_decisions.len() != self.snapshots.len()
|
||||||
|
{
|
||||||
|
return Err("signal_book_decision_coverage_incomplete".into());
|
||||||
|
}
|
||||||
|
let mut index = BTreeMap::new();
|
||||||
|
let mut previous = None;
|
||||||
|
let mut total_actions = 0usize;
|
||||||
|
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
|
||||||
|
if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at]
|
||||||
|
.iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) {
|
||||||
|
return Err("signal_timestamp_requires_microsecond_precision".into());
|
||||||
|
}
|
||||||
|
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
|
||||||
|
return Err("signal_book_decisions_duplicate_or_unordered".into());
|
||||||
|
}
|
||||||
|
previous = Some(*expected);
|
||||||
|
if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected
|
||||||
|
|| snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at
|
||||||
|
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|
||||||
|
|| snapshot.generated_at < snapshot.input_available_at
|
||||||
|
|| self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff)
|
||||||
|
{
|
||||||
|
return Err("signal_book_future_or_invalid_input".into());
|
||||||
|
}
|
||||||
|
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
|
||||||
|
return Err("observed_signal_not_available_at_decision".into());
|
||||||
|
}
|
||||||
|
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
|
||||||
|
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
|
||||||
|
let mut action_keys = BTreeSet::new();
|
||||||
|
let mut target_symbols = BTreeSet::new();
|
||||||
|
let mut reductions = BTreeSet::new();
|
||||||
|
let mut total_weight = 0.0;
|
||||||
|
for action in &snapshot.actions {
|
||||||
|
let symbol = action.symbol();
|
||||||
|
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
|
||||||
|
let kind = match action {
|
||||||
|
SignalAction::TargetWeight { weight, .. } => {
|
||||||
|
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
|
||||||
|
target_symbols.insert(symbol);
|
||||||
|
total_weight += weight;
|
||||||
|
"target"
|
||||||
|
}
|
||||||
|
SignalAction::BuyCondition { .. } => "buy_condition",
|
||||||
|
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
|
||||||
|
SignalAction::Reduce { remaining_ratio, .. } => {
|
||||||
|
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
|
||||||
|
reductions.insert(symbol);
|
||||||
|
"reduce"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
|
||||||
|
}
|
||||||
|
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
|
||||||
|
if snapshot.complete_targets && !reductions.is_empty() {
|
||||||
|
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
|
||||||
|
}
|
||||||
|
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
|
||||||
|
for symbol in &reductions {
|
||||||
|
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
|
||||||
|
return Err("signal_exit_reduction_conflict".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index.insert(shanghai(*expected), number);
|
||||||
|
}
|
||||||
|
if self.content_sha256()? != self.version_sha256 {
|
||||||
|
return Err("signal_book_content_hash_mismatch".into());
|
||||||
|
}
|
||||||
|
Ok(ValidatedSignalBook { book: self, index })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidatedSignalBook {
|
||||||
|
pub fn require_observed(&self) -> Result<(), String> {
|
||||||
|
if self.book.provenance != SignalProvenance::Observed {
|
||||||
|
return Err("reconstructed_signal_forbidden_in_online_execution".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
|
||||||
|
pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 }
|
||||||
|
|
||||||
|
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
|
||||||
|
self.index.keys().map(|value| value.date()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn symbols(&self) -> BTreeSet<String> {
|
||||||
|
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
|
||||||
|
.map(|action| action.symbol().to_owned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
|
||||||
|
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
|
||||||
|
if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() {
|
||||||
|
return Err("observed_signal_consumption_clock_missing".into());
|
||||||
|
}
|
||||||
|
let consumption_clock=ctx.current_datetime()
|
||||||
|
.unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session"));
|
||||||
|
let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily;
|
||||||
|
if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date {
|
||||||
|
return Err("next_open_signal_contains_execution_session_inputs".into());
|
||||||
|
}
|
||||||
|
if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock {
|
||||||
|
return Err("signal_not_available_at_consumption_clock".into());
|
||||||
|
}
|
||||||
|
if self.book.provenance == SignalProvenance::Observed
|
||||||
|
&& (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) {
|
||||||
|
return Err("observed_signal_published_after_consumption_clock".into());
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
|
||||||
|
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
|
||||||
|
.next().is_some_and(|(at,_)|at.date()==execution_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
|
||||||
|
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
|
||||||
|
execution_date.and_hms_opt(9, 30, 0).expect("next open")
|
||||||
|
} else {
|
||||||
|
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
|
||||||
|
};
|
||||||
|
self.index.get(&at).map(|index| &self.book.snapshots[*index])
|
||||||
|
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
|
||||||
|
let snapshot = self.snapshot_for(ctx)?;
|
||||||
|
self.snapshot_intents(snapshot, ctx.portfolio)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
|
||||||
|
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
|
||||||
|
let mut intents = Vec::new();
|
||||||
|
let mut weights = BTreeMap::new();
|
||||||
|
for action in &snapshot.actions {
|
||||||
|
match action {
|
||||||
|
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
|
||||||
|
weights.insert(symbol.clone(), *weight);
|
||||||
|
}
|
||||||
|
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
|
||||||
|
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
|
||||||
|
}),
|
||||||
|
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
|
||||||
|
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
|
||||||
|
}),
|
||||||
|
SignalAction::Reduce { symbol, remaining_ratio } => {
|
||||||
|
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
|
||||||
|
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
|
||||||
|
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
|
||||||
|
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignalAction::BuyCondition { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.complete_targets {
|
||||||
|
if weights.is_empty() {
|
||||||
|
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
|
||||||
|
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
|
||||||
|
order_prices: None, valuation_prices: None, reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(intents)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
|
||||||
|
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
|
||||||
|
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
|
||||||
|
_ => None,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Duration;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn book() -> SignalBook {
|
||||||
|
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
|
||||||
|
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
|
||||||
|
seal(SignalBook {
|
||||||
|
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
|
||||||
|
model_sha256: Some("d".repeat(64)),
|
||||||
|
knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()),
|
||||||
|
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
|
||||||
|
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
|
||||||
|
signal_at: source,
|
||||||
|
decision_at: decision, input_as_of: source, input_available_at: source,
|
||||||
|
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
|
||||||
|
input_sha256: "c".repeat(64), complete_targets: true,
|
||||||
|
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seal(mut book:SignalBook)->SignalBook {
|
||||||
|
book.version_sha256=book.content_sha256().unwrap();
|
||||||
|
book
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at_context<T>(at: Option<NaiveDateTime>, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T {
|
||||||
|
let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![crate::BenchmarkSnapshot {
|
||||||
|
date:NaiveDate::from_ymd_opt(2025,1,6).unwrap(), benchmark:"clock-fixture".into(),
|
||||||
|
open:100.0, close:100.0, prev_close:100.0, volume:1,
|
||||||
|
}]).unwrap();
|
||||||
|
let portfolio = PortfolioState::new(10_000.0);
|
||||||
|
let symbols = BTreeSet::new();
|
||||||
|
action(&StrategyContext {
|
||||||
|
execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(),
|
||||||
|
decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0,
|
||||||
|
data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[],
|
||||||
|
dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None,
|
||||||
|
active_datetime:at, order_events:&[], fills:&[],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() {
|
||||||
|
let mut raw = book();
|
||||||
|
raw.provenance=SignalProvenance::Observed;
|
||||||
|
raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap();
|
||||||
|
let value=seal(raw).validate().unwrap();
|
||||||
|
for clock in ["2025-01-06T15:00:00", "2025-01-07T08:45:00"] {
|
||||||
|
at_context(Some(clock.parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock");
|
||||||
|
assert!(ctx.portfolio.positions().is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap().len(),1);
|
||||||
|
assert!(ctx.portfolio.positions().is_empty());
|
||||||
|
});
|
||||||
|
at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() {
|
||||||
|
let value=book().validate().unwrap();
|
||||||
|
at_context(Some("2025-01-06T15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
|
||||||
|
at_context(Some("2025-01-06T14:59:59".parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock");
|
||||||
|
});
|
||||||
|
let mut raw=book();
|
||||||
|
raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of;
|
||||||
|
raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of;
|
||||||
|
let value=seal(raw).validate().unwrap();
|
||||||
|
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
|
||||||
|
assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_reconstruction_is_not_online_publication() {
|
||||||
|
let validated = book().validate().unwrap();
|
||||||
|
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
|
||||||
|
let mut observed = book();
|
||||||
|
observed.provenance = SignalProvenance::Observed;
|
||||||
|
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
|
||||||
|
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
|
||||||
|
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
|
||||||
|
seal(observed).validate().unwrap().require_observed().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_future_inputs_and_model_knowledge() {
|
||||||
|
for field in 0..3 {
|
||||||
|
let mut value = book();
|
||||||
|
let future = value.snapshots[0].decision_at + Duration::seconds(1);
|
||||||
|
match field {
|
||||||
|
0 => value.snapshots[0].input_as_of = future,
|
||||||
|
1 => value.snapshots[0].input_available_at = future,
|
||||||
|
_ => value.knowledge_cutoff = Some(future),
|
||||||
|
}
|
||||||
|
assert!(value.validate().unwrap_err().contains("future"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_quantities_prices_and_unknown_signal_fields() {
|
||||||
|
for name in ["quantity", "execution_price", "account_id", "cash"] {
|
||||||
|
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
|
||||||
|
action[name] = json!(100);
|
||||||
|
assert!(serde_json::from_value::<SignalAction>(action).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_and_duplicate_actions_fail_closed() {
|
||||||
|
let mut value = book();
|
||||||
|
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
|
||||||
|
assert!(value.validate().unwrap_err().contains("coverage"));
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots.push(value.snapshots[0].clone());
|
||||||
|
value.expected_decisions.push(value.expected_decisions[0]);
|
||||||
|
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||||
|
let mut value = book();
|
||||||
|
let repeated = value.snapshots[0].actions[0].clone();
|
||||||
|
value.snapshots[0].actions.push(repeated);
|
||||||
|
assert!(value.validate().unwrap_err().contains("duplicate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
|
||||||
|
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
|
||||||
|
assert!(value.validate().is_err());
|
||||||
|
}
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
|
||||||
|
assert!(value.validate().unwrap_err().contains("exposure"));
|
||||||
|
let mut value = book();
|
||||||
|
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
|
||||||
|
assert!(value.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_open_uses_decision_session_and_never_nearest_signal() {
|
||||||
|
let value = book().validate().unwrap();
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
|
||||||
|
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
|
||||||
|
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
|
||||||
|
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduction_is_resolved_from_each_accounts_actual_position() {
|
||||||
|
let mut raw = book();
|
||||||
|
raw.snapshots[0].complete_targets = false;
|
||||||
|
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
|
||||||
|
let value = seal(raw).validate().unwrap();
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||||
|
for (held, expected) in [(1000,500),(3000,1500)] {
|
||||||
|
let mut portfolio = PortfolioState::new(100_000.0);
|
||||||
|
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
|
||||||
|
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||||
|
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
|
||||||
|
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
|
||||||
|
}
|
||||||
|
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
|
||||||
|
let mut raw = book();
|
||||||
|
raw.snapshots[0].actions.clear();
|
||||||
|
let value = seal(raw).validate().unwrap();
|
||||||
|
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
|
||||||
|
let mut portfolio = PortfolioState::new(100_000.0);
|
||||||
|
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
|
||||||
|
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
|
||||||
|
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_spec_consumes_book_without_running_another_selection() {
|
||||||
|
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
|
||||||
|
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
|
||||||
|
assert!(!config.rotation_enabled && config.signal_book.is_some());
|
||||||
|
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_valid_contents_must_not_reuse_a_version_hash() {
|
||||||
|
let mut raw=book();
|
||||||
|
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
|
||||||
|
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
|
||||||
|
seal(raw).validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completed_daily_inputs_may_be_published_after_market_close() {
|
||||||
|
let mut raw=book();
|
||||||
|
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
|
||||||
|
raw.snapshots[0].decision_at=raw.expected_decisions[0];
|
||||||
|
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
|
||||||
|
raw.snapshots[0].signal_at=raw.snapshots[0].input_available_at;
|
||||||
|
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
|
||||||
|
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
|
||||||
|
raw.provenance=SignalProvenance::Observed;
|
||||||
|
seal(raw).validate().unwrap().require_observed().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct StrategyDecision {
|
pub struct StrategyDecision {
|
||||||
|
pub buy_denials: BTreeMap<String, String>,
|
||||||
pub rebalance: bool,
|
pub rebalance: bool,
|
||||||
pub target_weights: BTreeMap<String, f64>,
|
pub target_weights: BTreeMap<String, f64>,
|
||||||
pub exit_symbols: BTreeSet<String>,
|
pub exit_symbols: BTreeSet<String>,
|
||||||
@@ -987,7 +988,20 @@ pub struct StrategyDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StrategyDecision {
|
impl StrategyDecision {
|
||||||
|
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
|
||||||
|
let mut symbols = BTreeSet::new();
|
||||||
|
if self.rebalance {
|
||||||
|
symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
|
||||||
|
}
|
||||||
|
for intent in &self.order_intents {
|
||||||
|
intent.collect_potential_buy_symbols(open_orders, &mut symbols);
|
||||||
|
}
|
||||||
|
symbols.retain(|symbol| !symbol.trim().is_empty());
|
||||||
|
symbols
|
||||||
|
}
|
||||||
|
|
||||||
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
||||||
|
self.buy_denials.append(&mut other.buy_denials);
|
||||||
self.rebalance |= other.rebalance;
|
self.rebalance |= other.rebalance;
|
||||||
self.target_weights.append(&mut other.target_weights);
|
self.target_weights.append(&mut other.target_weights);
|
||||||
self.exit_symbols.append(&mut other.exit_symbols);
|
self.exit_symbols.append(&mut other.exit_symbols);
|
||||||
@@ -998,7 +1012,8 @@ impl StrategyDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
!self.rebalance
|
self.buy_denials.is_empty()
|
||||||
|
&& !self.rebalance
|
||||||
&& self.target_weights.is_empty()
|
&& self.target_weights.is_empty()
|
||||||
&& self.exit_symbols.is_empty()
|
&& self.exit_symbols.is_empty()
|
||||||
&& self.order_intents.is_empty()
|
&& self.order_intents.is_empty()
|
||||||
@@ -1214,6 +1229,42 @@ pub enum OrderIntent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OrderIntent {
|
impl OrderIntent {
|
||||||
|
fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet<String>) {
|
||||||
|
match self.unwrapped() {
|
||||||
|
Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); }
|
||||||
|
Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); }
|
||||||
|
Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); }
|
||||||
|
Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); }
|
||||||
|
Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); }
|
||||||
|
Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); }
|
||||||
|
Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); }
|
||||||
|
Self::TargetPortfolioSmart { target_weights, .. } => {
|
||||||
|
symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone()));
|
||||||
|
}
|
||||||
|
Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => {
|
||||||
|
if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id)
|
||||||
|
&& order.side == OrderSide::Buy
|
||||||
|
&& (new_total_quantity.is_some_and(|value| value > order.requested_quantity)
|
||||||
|
|| new_limit_price.is_some_and(|value| value > order.limit_price))
|
||||||
|
{
|
||||||
|
symbols.insert(order.symbol.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Shares { .. } | Self::LimitShares { .. }
|
||||||
|
| Self::Lots { .. } | Self::LimitLots { .. }
|
||||||
|
| Self::TargetShares { .. } | Self::LimitTargetShares { .. }
|
||||||
|
| Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. }
|
||||||
|
| Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. }
|
||||||
|
| Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. }
|
||||||
|
| Self::TargetPercent { .. } | Self::LimitTargetPercent { .. }
|
||||||
|
| Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. }
|
||||||
|
| Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. }
|
||||||
|
| Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. }
|
||||||
|
| Self::Futures { .. } => {}
|
||||||
|
Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
|
pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
|
Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce {
|
||||||
@@ -1569,6 +1620,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
if self.config.in_skip_window(ctx.decision_date) {
|
if self.config.in_skip_window(ctx.decision_date) {
|
||||||
self.last_gross_exposure = Some(0.0);
|
self.last_gross_exposure = Some(0.0);
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||||
@@ -1590,6 +1642,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
if message.contains("signal series insufficient") =>
|
if message.contains("signal series insufficient") =>
|
||||||
{
|
{
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1765,6 +1818,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
|||||||
self.last_gross_exposure = Some(gross_exposure);
|
self.last_gross_exposure = Some(gross_exposure);
|
||||||
|
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance,
|
rebalance,
|
||||||
target_weights,
|
target_weights,
|
||||||
exit_symbols,
|
exit_symbols,
|
||||||
@@ -2773,6 +2827,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
let lagged_execution = ctx.is_lagged_execution();
|
let lagged_execution = ctx.is_lagged_execution();
|
||||||
if self.config.in_skip_window(signal_date) {
|
if self.config.in_skip_window(signal_date) {
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||||
@@ -2803,6 +2858,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
if message.contains("insufficient benchmark") =>
|
if message.contains("insufficient benchmark") =>
|
||||||
{
|
{
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3013,6 +3069,7 @@ impl Strategy for OmniMicroCapStrategy {
|
|||||||
];
|
];
|
||||||
|
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols,
|
exit_symbols,
|
||||||
@@ -3154,6 +3211,7 @@ mod tests {
|
|||||||
pe_ttm: 12.0,
|
pe_ttm: 12.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
|||||||
title: "期货 runtime action 与提交校验".to_string(),
|
title: "期货 runtime action 与提交校验".to_string(),
|
||||||
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
|
||||||
},
|
},
|
||||||
|
ManualSection {
|
||||||
|
title: "trading.automatic_trade_protection(...)".to_string(),
|
||||||
|
detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(),
|
||||||
|
},
|
||||||
ManualSection {
|
ManualSection {
|
||||||
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
|
||||||
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForce:DAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ mod tests {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(0.01),
|
turnover_ratio: Some(0.01),
|
||||||
effective_turnover_ratio: Some(0.01),
|
effective_turnover_ratio: Some(0.01),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||||
|
use fidc_core::{
|
||||||
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
|
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument,
|
||||||
|
MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy,
|
||||||
|
PlatformExprStrategyConfig, PlatformTradeAction, PriceField,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn d(day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
|
||||||
|
}
|
||||||
|
fn data() -> DataSet {
|
||||||
|
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||||
|
DataSet::from_components(
|
||||||
|
vec![Instrument {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
name: "测试".into(),
|
||||||
|
board: "SZ".into(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".into(),
|
||||||
|
}],
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| DailyMarketSnapshot {
|
||||||
|
date: *date,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
timestamp: Some(format!("{date} 15:00:00")),
|
||||||
|
day_open: 10.0,
|
||||||
|
open: 10.0,
|
||||||
|
high: 10.0,
|
||||||
|
low: 10.0,
|
||||||
|
close: 10.0,
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 10.0,
|
||||||
|
ask1: 10.0,
|
||||||
|
prev_close: 10.0,
|
||||||
|
volume: 100_000,
|
||||||
|
minute_volume: 100_000,
|
||||||
|
bid1_volume: 100_000,
|
||||||
|
ask1_volume: 100_000,
|
||||||
|
trading_phase: Some("continuous".into()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit: 11.0,
|
||||||
|
lower_limit: 9.0,
|
||||||
|
price_tick: 0.01,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| fidc_core::DailyFactorSnapshot {
|
||||||
|
date: *date,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
market_cap_bn: 10.0,
|
||||||
|
free_float_cap_bn: 10.0,
|
||||||
|
pe_ttm: 10.0,
|
||||||
|
turnover_ratio: None,
|
||||||
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: Some(1.0),
|
||||||
|
extra_factors: Default::default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| CandidateEligibility {
|
||||||
|
date: *date,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
is_st: false,
|
||||||
|
is_star_st: false,
|
||||||
|
is_new_listing: false,
|
||||||
|
is_paused: false,
|
||||||
|
allow_buy: true,
|
||||||
|
allow_sell: true,
|
||||||
|
is_kcb: false,
|
||||||
|
is_one_yuan: false,
|
||||||
|
risk_level_code: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| BenchmarkSnapshot {
|
||||||
|
date: *date,
|
||||||
|
benchmark: "000300.SH".into(),
|
||||||
|
open: 100.0,
|
||||||
|
close: 100.0,
|
||||||
|
prev_close: 100.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||||
|
PlatformTradeAction::Order {
|
||||||
|
kind: PlatformExplicitOrderKind::Shares,
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
amount_expr: quantity.into(),
|
||||||
|
when_expr: Some(when.into()),
|
||||||
|
limit_price_expr: None,
|
||||||
|
time_in_force: None,
|
||||||
|
start_time_expr: None,
|
||||||
|
end_time_expr: None,
|
||||||
|
reason: "configured_strategy_action".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||||
|
let mut config = PlatformExprStrategyConfig::generic();
|
||||||
|
config.signal_symbol = "000001.SZ".into();
|
||||||
|
config.benchmark_symbol = "000300.SH".into();
|
||||||
|
config.rotation_enabled = false;
|
||||||
|
config.automatic_trade_protection = policy;
|
||||||
|
config.explicit_actions = vec![
|
||||||
|
action(
|
||||||
|
"100",
|
||||||
|
"decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"",
|
||||||
|
),
|
||||||
|
action("-100", "decision_date >= \"2026-09-14\""),
|
||||||
|
];
|
||||||
|
config.matching_type = MatchingType::CurrentBarClose;
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
BacktestEngine::new(
|
||||||
|
data(),
|
||||||
|
PlatformExprStrategy::new(config),
|
||||||
|
broker,
|
||||||
|
BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000300.SH".into(),
|
||||||
|
start_date: Some(d(11)),
|
||||||
|
end_date: Some(d(18)),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() {
|
||||||
|
let result = run(AutomaticTradeProtection {
|
||||||
|
buy_protection_days: 3,
|
||||||
|
sell_cooldown_days: 3,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.map(|fill| (fill.date, fill.side, fill.quantity))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)]
|
||||||
|
);
|
||||||
|
assert!(!result.order_events.iter().any(|order| order.date == d(14)
|
||||||
|
|| order.date == d(15)
|
||||||
|
|| order.date == d(16)
|
||||||
|
|| order.date == d(18)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() {
|
||||||
|
let result = run(AutomaticTradeProtection {
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d(11),
|
||||||
|
end_date: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(result.fills.is_empty());
|
||||||
|
assert!(result.order_events.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() {
|
||||||
|
let result = run(AutomaticTradeProtection {
|
||||||
|
max_holding_days: 1,
|
||||||
|
buy_protection_days: 3,
|
||||||
|
sell_cooldown_days: 3,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.map(|fill| (fill.date, fill.side))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.order_events
|
||||||
|
.iter()
|
||||||
|
.any(|order| order.reason == "max_holding_days_exit")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() {
|
||||||
|
let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]});
|
||||||
|
for key in ["automaticTradeProtection", "automatic_trade_protection"] {
|
||||||
|
let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}});
|
||||||
|
let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap();
|
||||||
|
assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3);
|
||||||
|
assert_eq!(cfg.max_holding_days, Some(90));
|
||||||
|
assert_eq!(cfg.automatic_trade_protection.locks.len(), 1);
|
||||||
|
}
|
||||||
|
let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}});
|
||||||
|
assert!(
|
||||||
|
fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("conflicting maximum")
|
||||||
|
);
|
||||||
|
let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}});
|
||||||
|
assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
|
||||||
|
let base = data();
|
||||||
|
let dates = [11, 14, 15, 16, 17, 18].map(d);
|
||||||
|
let symbols = ["000001.SZ", "000002.SZ"];
|
||||||
|
let dataset = DataSet::from_components(
|
||||||
|
symbols
|
||||||
|
.iter()
|
||||||
|
.map(|symbol| {
|
||||||
|
let mut row = base.instruments()["000001.SZ"].clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.flat_map(|date| {
|
||||||
|
symbols.iter().map(|symbol| {
|
||||||
|
let mut row = base.market(*date, "000001.SZ").unwrap().clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.flat_map(|date| {
|
||||||
|
symbols.iter().map(|symbol| {
|
||||||
|
let mut row = base.factor(*date, "000001.SZ").unwrap().clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.flat_map(|date| {
|
||||||
|
symbols.iter().map(|symbol| {
|
||||||
|
let mut row = base.candidate(*date, "000001.SZ").unwrap().clone();
|
||||||
|
row.symbol = (*symbol).into();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dates
|
||||||
|
.iter()
|
||||||
|
.map(|date| BenchmarkSnapshot {
|
||||||
|
date: *date,
|
||||||
|
benchmark: "000300.SH".into(),
|
||||||
|
open: 100.0,
|
||||||
|
close: 100.0,
|
||||||
|
prev_close: 100.0,
|
||||||
|
volume: 100_000,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut config = PlatformExprStrategyConfig::generic();
|
||||||
|
config.signal_symbol = "000001.SZ".into();
|
||||||
|
config.benchmark_symbol = "000300.SH".into();
|
||||||
|
config.strategy_name = "protection_test".into();
|
||||||
|
config.max_positions = 1;
|
||||||
|
config.selection_limit_expr = "1".into();
|
||||||
|
config.refresh_rate = 1;
|
||||||
|
config.exposure_expr = "0.5".into();
|
||||||
|
config.market_cap_lower_expr = "0".into();
|
||||||
|
config.market_cap_upper_expr = "100".into();
|
||||||
|
config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into();
|
||||||
|
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||||
|
locks: vec![AutomaticTradeLock {
|
||||||
|
symbol: "000001.SZ".into(),
|
||||||
|
start_date: d(14),
|
||||||
|
end_date: Some(d(16)),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
let result = BacktestEngine::new(
|
||||||
|
dataset,
|
||||||
|
PlatformExprStrategy::new(config),
|
||||||
|
broker,
|
||||||
|
BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000300.SH".into(),
|
||||||
|
start_date: Some(d(11)),
|
||||||
|
end_date: Some(d(18)),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.fills
|
||||||
|
.first()
|
||||||
|
.map(|fill| (fill.symbol.as_str(), fill.date)),
|
||||||
|
Some(("000001.SZ", d(11)))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.any(|fill| [d(14), d(15), d(16)].contains(&fill.date)),
|
||||||
|
"{:?}",
|
||||||
|
result.fills
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.fills.iter().any(|fill| fill.symbol == "000002.SZ"
|
||||||
|
&& fill.side == OrderSide::Buy
|
||||||
|
&& fill.date == d(17)),
|
||||||
|
"{:?}",
|
||||||
|
result.fills
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy {
|
|||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -144,6 +145,7 @@ fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -280,6 +283,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -290,6 +294,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ use chrono::{Duration, NaiveDate, NaiveTime};
|
|||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||||
StrategyDecision,
|
StrategyDecision,
|
||||||
};
|
};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
@@ -15,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
|
|||||||
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fixture_instruments() -> Vec<Instrument> {
|
||||||
|
vec![Instrument {
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
name: "quote-plan-fixture".to_string(),
|
||||||
|
board: "SZ".to_string(),
|
||||||
|
round_lot: 100,
|
||||||
|
listed_at: Some(d(2020, 1, 1)),
|
||||||
|
delisted_at: None,
|
||||||
|
status: "active".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct DecisionQuoteReader {
|
struct DecisionQuoteReader {
|
||||||
day_count: usize,
|
day_count: usize,
|
||||||
@@ -62,12 +75,197 @@ 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(
|
||||||
|
fixture_instruments(),
|
||||||
|
vec![DailyMarketSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: Some(format!("{date} 15:00:00")),
|
||||||
|
day_open: 10.0,
|
||||||
|
open: 10.0,
|
||||||
|
high: 10.2,
|
||||||
|
low: 9.9,
|
||||||
|
close: 10.0,
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 10.0,
|
||||||
|
ask1: 10.0,
|
||||||
|
prev_close: 9.8,
|
||||||
|
volume: 10_000,
|
||||||
|
minute_volume: 1_000,
|
||||||
|
bid1_volume: 10_000,
|
||||||
|
ask1_volume: 10_000,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
paused: false,
|
||||||
|
upper_limit: 10.78,
|
||||||
|
lower_limit: 8.82,
|
||||||
|
price_tick: 0.01,
|
||||||
|
}],
|
||||||
|
vec![DailyFactorSnapshot {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
market_cap_bn: 10.0,
|
||||||
|
free_float_cap_bn: 10.0,
|
||||||
|
pe_ttm: 10.0,
|
||||||
|
turnover_ratio: None,
|
||||||
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
|
extra_factors: Default::default(),
|
||||||
|
}],
|
||||||
|
vec![CandidateEligibility {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
is_st: false,
|
||||||
|
is_star_st: false,
|
||||||
|
is_new_listing: false,
|
||||||
|
is_paused: false,
|
||||||
|
allow_buy: true,
|
||||||
|
allow_sell: true,
|
||||||
|
is_kcb: false,
|
||||||
|
is_one_yuan: false,
|
||||||
|
risk_level_code: None,
|
||||||
|
}],
|
||||||
|
vec![BenchmarkSnapshot {
|
||||||
|
date,
|
||||||
|
benchmark: "000852.SH".to_string(),
|
||||||
|
open: 1000.0,
|
||||||
|
close: 1001.0,
|
||||||
|
prev_close: 999.0,
|
||||||
|
volume: 1_000_000,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("dataset")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||||
|
let date = d(2026, 1, 5);
|
||||||
|
let data = single_day_quote_plan_data(date);
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
let config = BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000852.SH".to_string(),
|
||||||
|
start_date: Some(date),
|
||||||
|
end_date: Some(date),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
};
|
||||||
|
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let loader_calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let strategy = NoLoaderDecisionQuoteStrategy {
|
||||||
|
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||||
|
};
|
||||||
|
let captured_loader_calls = Arc::clone(&loader_calls);
|
||||||
|
let preplanned = Arc::new(BTreeMap::from([(
|
||||||
|
date,
|
||||||
|
BTreeSet::from(["000001.SZ".to_string()]),
|
||||||
|
)]));
|
||||||
|
let mut engine = BacktestEngine::new(data, strategy, broker, config)
|
||||||
|
.with_execution_quote_loader(move |request| {
|
||||||
|
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
||||||
|
Ok(request
|
||||||
|
.symbols
|
||||||
|
.into_iter()
|
||||||
|
.map(|symbol| IntradayExecutionQuote {
|
||||||
|
date: request.date,
|
||||||
|
symbol,
|
||||||
|
timestamp: request.date.and_time(t(10, 17, 59)),
|
||||||
|
last_price: 10.0,
|
||||||
|
bid1: 10.0,
|
||||||
|
ask1: 10.0,
|
||||||
|
bid1_volume: 10_000,
|
||||||
|
ask1_volume: 10_000,
|
||||||
|
volume_delta: 10_000,
|
||||||
|
amount_delta: 100_000.0,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.with_preplanned_decision_quote_symbols_by_date(preplanned);
|
||||||
|
|
||||||
|
engine.run().expect("backtest should run");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||||
|
0,
|
||||||
|
"the strategy plan must not be recomputed after a complete plan is supplied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*loader_calls.lock().expect("loader counter mutex"),
|
||||||
|
1,
|
||||||
|
"the supplied symbols must still pass through the normal quote loader"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_skips_decision_quote_symbol_plan_without_loader() {
|
||||||
|
let date = d(2026, 1, 5);
|
||||||
|
let data = single_day_quote_plan_data(date);
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Close,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::CurrentBarClose);
|
||||||
|
let config = BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000852.SH".to_string(),
|
||||||
|
start_date: Some(date),
|
||||||
|
end_date: Some(date),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Close,
|
||||||
|
};
|
||||||
|
let symbol_plan_calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let strategy = NoLoaderDecisionQuoteStrategy {
|
||||||
|
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||||
|
};
|
||||||
|
let mut engine = BacktestEngine::new(data, strategy, broker, config);
|
||||||
|
|
||||||
|
engine.run().expect("backtest should run");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||||
|
0,
|
||||||
|
"a preloaded/no-loader engine cannot use a newly computed quote symbol plan"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||||
let first = d(2026, 1, 5);
|
let first = d(2026, 1, 5);
|
||||||
let second = d(2026, 1, 6);
|
let second = d(2026, 1, 6);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
Vec::new(),
|
fixture_instruments(),
|
||||||
vec![
|
vec![
|
||||||
DailyMarketSnapshot {
|
DailyMarketSnapshot {
|
||||||
date: first,
|
date: first,
|
||||||
@@ -125,6 +323,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -135,6 +334,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -235,7 +435,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
|||||||
let first = d(2026, 1, 5);
|
let first = d(2026, 1, 5);
|
||||||
let second = d(2026, 1, 6);
|
let second = d(2026, 1, 6);
|
||||||
let data = DataSet::from_components_with_actions_and_quotes(
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
Vec::new(),
|
fixture_instruments(),
|
||||||
vec![
|
vec![
|
||||||
DailyMarketSnapshot {
|
DailyMarketSnapshot {
|
||||||
date: first,
|
date: first,
|
||||||
@@ -293,6 +493,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -303,6 +504,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -468,7 +670,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
|||||||
let first = d(2026, 1, 5);
|
let first = d(2026, 1, 5);
|
||||||
let second = d(2026, 1, 6);
|
let second = d(2026, 1, 6);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
Vec::new(),
|
fixture_instruments(),
|
||||||
vec![
|
vec![
|
||||||
DailyMarketSnapshot {
|
DailyMarketSnapshot {
|
||||||
date: first,
|
date: first,
|
||||||
@@ -526,6 +728,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -536,6 +739,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy {
|
|||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -170,6 +171,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -180,6 +182,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -190,6 +193,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -200,6 +204,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -436,6 +441,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -446,6 +452,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -456,6 +463,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -144,6 +145,7 @@ fn factor_row(
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors,
|
extra_factors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +295,7 @@ impl Strategy for HookProbeStrategy {
|
|||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.push(format!("on_day:{}", ctx.execution_date));
|
.push(format!("on_day:{}", ctx.execution_date));
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -332,6 +335,7 @@ impl Strategy for AuctionOrderStrategy {
|
|||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -375,6 +379,7 @@ impl Strategy for FuturesOrderStrategy {
|
|||||||
return Ok(StrategyDecision::default());
|
return Ok(StrategyDecision::default());
|
||||||
}
|
}
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -718,6 +723,7 @@ impl Strategy for LimitCarryStrategy {
|
|||||||
}
|
}
|
||||||
self.issued = true;
|
self.issued = true;
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -801,6 +807,7 @@ impl Strategy for UniverseDirectiveStrategy {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -842,6 +849,7 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -883,6 +891,7 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
}
|
}
|
||||||
self.ordered = true;
|
self.ordered = true;
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -985,6 +994,7 @@ impl Strategy for OrderInspectionStrategy {
|
|||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1028,6 +1038,7 @@ impl Strategy for AccountFlowStrategy {
|
|||||||
return Ok(StrategyDecision::default());
|
return Ok(StrategyDecision::default());
|
||||||
}
|
}
|
||||||
Ok(StrategyDecision {
|
Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1137,6 +1148,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -1147,6 +1159,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1353,6 +1366,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1453,6 +1467,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2128,15 +2143,15 @@ fn strategy_context_exposes_advanced_data_helpers() {
|
|||||||
fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
let data = DataSet::from_components_with_actions_and_quotes(
|
let data = DataSet::from_components_with_actions_and_quotes(
|
||||||
vec![Instrument {
|
["000001.SZ", "000002.SZ"].into_iter().map(|symbol| Instrument {
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: symbol.to_string(),
|
||||||
name: "Anchor".to_string(),
|
name: "Anchor".to_string(),
|
||||||
board: "SZ".to_string(),
|
board: "SZ".to_string(),
|
||||||
round_lot: 100,
|
round_lot: 100,
|
||||||
listed_at: Some(d(2020, 1, 1)),
|
listed_at: Some(d(2020, 1, 1)),
|
||||||
delisted_at: None,
|
delisted_at: None,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
}],
|
}).collect(),
|
||||||
vec![DailyMarketSnapshot {
|
vec![DailyMarketSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -2159,7 +2174,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
upper_limit: 11.0,
|
upper_limit: 11.0,
|
||||||
lower_limit: 9.0,
|
lower_limit: 9.0,
|
||||||
price_tick: 0.01,
|
price_tick: 0.01,
|
||||||
}],
|
}, market_row(date, "000002.SZ", 20.0, 20.4)],
|
||||||
vec![DailyFactorSnapshot {
|
vec![DailyFactorSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -2168,6 +2183,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2463,6 +2479,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -2631,6 +2648,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2874,6 +2892,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -2884,6 +2903,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3069,6 +3089,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3079,6 +3100,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3266,6 +3288,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3276,6 +3299,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3286,6 +3310,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3521,6 +3546,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3531,6 +3557,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3541,6 +3568,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3886,6 +3914,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3896,6 +3925,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
|
|||||||
pe_ttm: 12.0,
|
pe_ttm: 12.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -4040,6 +4070,7 @@ fn engine_exposes_current_process_context_to_strategies() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4112,6 +4143,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
|||||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
if ctx.execution_date == d(2025, 5, 26) {
|
if ctx.execution_date == d(2025, 5, 26) {
|
||||||
return Ok(StrategyDecision {
|
return Ok(StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4130,7 +4162,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_carries_position_price_when_current_market_row_is_missing() {
|
fn engine_rejects_an_unexplained_missing_holding_close() {
|
||||||
let date1 = d(2025, 5, 26);
|
let date1 = d(2025, 5, 26);
|
||||||
let date2 = d(2025, 5, 27);
|
let date2 = d(2025, 5, 27);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
@@ -4198,20 +4230,16 @@ fn engine_carries_position_price_when_current_market_row_is_missing() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = engine
|
let error = engine
|
||||||
.run()
|
.run()
|
||||||
.expect("backtest should not fail on one missing holding row");
|
.expect_err("unknown missing market data must not become a carried close");
|
||||||
assert_eq!(result.equity_curve.len(), 2);
|
let detail = format!("{error:?}");
|
||||||
assert!(
|
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
|
||||||
result
|
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
|
||||||
.daily_holdings
|
|
||||||
.iter()
|
|
||||||
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing() {
|
fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
|
||||||
let date1 = d(2025, 5, 26);
|
let date1 = d(2025, 5, 26);
|
||||||
let date2 = d(2025, 5, 27);
|
let date2 = d(2025, 5, 27);
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
@@ -4301,14 +4329,10 @@ fn platform_strategy_skips_position_stop_take_when_current_market_row_is_missing
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = engine
|
let error = engine
|
||||||
.run()
|
.run()
|
||||||
.expect("platform strategy should hold through a missing current market row");
|
.expect_err("skipping a stop condition cannot fabricate the missing valuation");
|
||||||
assert_eq!(result.equity_curve.len(), 2);
|
let detail = format!("{error:?}");
|
||||||
assert!(
|
assert!(detail.contains("MissingSnapshot") && detail.contains("close price"));
|
||||||
result
|
assert!(detail.contains("601028.SH") && detail.contains("2025-05-27"));
|
||||||
.daily_holdings
|
|
||||||
.iter()
|
|
||||||
.any(|holding| holding.date == date2 && holding.symbol == "601028.SH")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ fn order_value_rounding_data(date: NaiveDate, symbol: &str, price: f64) -> DataS
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -119,6 +120,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -207,6 +209,7 @@ fn execute_single_value_order(
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
data,
|
data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -272,6 +275,7 @@ fn single_symbol_limit_price_data(
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -343,6 +347,7 @@ fn broker_executes_explicit_order_value_buy() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -395,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -495,6 +501,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -552,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -629,6 +637,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -681,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -757,6 +767,7 @@ fn broker_executes_target_shares_like_order_to() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -799,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -904,6 +916,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -914,6 +927,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
|||||||
pe_ttm: 14.0,
|
pe_ttm: 14.0,
|
||||||
turnover_ratio: Some(2.2),
|
turnover_ratio: Some(2.2),
|
||||||
effective_turnover_ratio: Some(2.0),
|
effective_turnover_ratio: Some(2.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -972,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1062,6 +1077,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
|||||||
pe_ttm: 14.0,
|
pe_ttm: 14.0,
|
||||||
turnover_ratio: Some(2.2),
|
turnover_ratio: Some(2.2),
|
||||||
effective_turnover_ratio: Some(2.0),
|
effective_turnover_ratio: Some(2.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1129,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1205,6 +1222,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1243,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
&mut percent_portfolio,
|
&mut percent_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1267,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() {
|
|||||||
&mut target_percent_portfolio,
|
&mut target_percent_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1329,6 +1349,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1368,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1435,6 +1457,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1474,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1538,6 +1562,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1576,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1653,6 +1679,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1692,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1712,8 +1740,9 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
fn broker_applies_explicit_historical_slippage_on_snapshot_fills() {
|
||||||
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
|
let previous_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
|
||||||
let data = DataSet::from_components(
|
let data = DataSet::from_components(
|
||||||
vec![Instrument {
|
vec![Instrument {
|
||||||
symbol: "000002.SZ".to_string(),
|
symbol: "000002.SZ".to_string(),
|
||||||
@@ -1724,20 +1753,20 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
delisted_at: None,
|
delisted_at: None,
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
}],
|
}],
|
||||||
vec![DailyMarketSnapshot {
|
[previous_date, date].into_iter().map(|day| DailyMarketSnapshot {
|
||||||
date,
|
date: day,
|
||||||
symbol: "000002.SZ".to_string(),
|
symbol: "000002.SZ".to_string(),
|
||||||
timestamp: Some("2024-01-10 10:18:00".to_string()),
|
timestamp: Some(format!("{day} 15:00:00")),
|
||||||
day_open: 10.0,
|
day_open: 10.0,
|
||||||
open: 10.0,
|
open: 10.0,
|
||||||
high: 10.1,
|
high: if day == previous_date { 10.1 } else { 10.9 },
|
||||||
low: 9.9,
|
low: if day == previous_date { 9.9 } else { 9.1 },
|
||||||
close: 10.0,
|
close: if day == previous_date { 10.0 } else { 10.8 },
|
||||||
last_price: 10.0,
|
last_price: 10.0,
|
||||||
bid1: 9.99,
|
bid1: 9.99,
|
||||||
ask1: 10.01,
|
ask1: 10.01,
|
||||||
prev_close: 10.0,
|
prev_close: 10.0,
|
||||||
volume: 100_000,
|
volume: if day == previous_date { 100_000 } else { 2_000_000 },
|
||||||
minute_volume: 100_000,
|
minute_volume: 100_000,
|
||||||
bid1_volume: 80_000,
|
bid1_volume: 80_000,
|
||||||
ask1_volume: 80_000,
|
ask1_volume: 80_000,
|
||||||
@@ -1746,7 +1775,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
upper_limit: 11.0,
|
upper_limit: 11.0,
|
||||||
lower_limit: 9.0,
|
lower_limit: 9.0,
|
||||||
price_tick: 0.01,
|
price_tick: 0.01,
|
||||||
}],
|
}).collect(),
|
||||||
vec![DailyFactorSnapshot {
|
vec![DailyFactorSnapshot {
|
||||||
date,
|
date,
|
||||||
symbol: "000002.SZ".to_string(),
|
symbol: "000002.SZ".to_string(),
|
||||||
@@ -1755,10 +1784,11 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
[previous_date, date].into_iter().map(|day| CandidateEligibility {
|
||||||
date,
|
date: day,
|
||||||
symbol: "000002.SZ".to_string(),
|
symbol: "000002.SZ".to_string(),
|
||||||
is_st: false,
|
is_st: false,
|
||||||
is_star_st: false,
|
is_star_st: false,
|
||||||
@@ -1769,15 +1799,15 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
is_kcb: false,
|
is_kcb: false,
|
||||||
is_one_yuan: false,
|
is_one_yuan: false,
|
||||||
risk_level_code: None,
|
risk_level_code: None,
|
||||||
}],
|
}).collect(),
|
||||||
vec![BenchmarkSnapshot {
|
[previous_date, date].into_iter().map(|day| BenchmarkSnapshot {
|
||||||
date,
|
date: day,
|
||||||
benchmark: "000300.SH".to_string(),
|
benchmark: "000300.SH".to_string(),
|
||||||
open: 100.0,
|
open: 100.0,
|
||||||
close: 100.0,
|
close: 100.0,
|
||||||
prev_close: 99.0,
|
prev_close: 99.0,
|
||||||
volume: 1_000_000,
|
volume: 1_000_000,
|
||||||
}],
|
}).collect(),
|
||||||
)
|
)
|
||||||
.expect("dataset");
|
.expect("dataset");
|
||||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||||
@@ -1786,7 +1816,9 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
ChinaEquityRuleHooks::default(),
|
ChinaEquityRuleHooks::default(),
|
||||||
PriceField::Open,
|
PriceField::Open,
|
||||||
)
|
)
|
||||||
.with_slippage_model(SlippageModel::Dynamic(DynamicSlippageConfig::new(
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false)
|
||||||
|
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
|
||||||
0.5, 0.3, 0.1,
|
0.5, 0.3, 0.1,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
@@ -1796,6 +1828,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1862,6 +1895,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -1916,6 +1950,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -1982,6 +2017,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2021,6 +2057,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2091,6 +2128,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2144,6 +2182,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2209,6 +2248,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2262,6 +2302,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2339,6 +2380,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2377,6 +2419,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2445,6 +2488,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2512,6 +2556,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2616,6 +2661,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2684,6 +2730,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2771,6 +2818,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -2864,6 +2912,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -2931,6 +2980,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -3011,6 +3061,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3029,23 +3080,24 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
|||||||
)
|
)
|
||||||
.expect("broker execution");
|
.expect("broker execution");
|
||||||
|
|
||||||
assert_eq!(report.fill_events.len(), 3);
|
assert_eq!(report.fill_events.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report
|
report
|
||||||
.fill_events
|
.fill_events
|
||||||
.iter()
|
.iter()
|
||||||
.map(|fill| fill.quantity)
|
.map(|fill| fill.quantity)
|
||||||
.sum::<u32>(),
|
.sum::<u32>(),
|
||||||
300
|
200
|
||||||
);
|
);
|
||||||
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
|
assert!(report.fill_events.iter().all(|fill| fill.quantity == 100));
|
||||||
|
assert!(report.fill_events.iter().all(|fill| fill.execution_timestamp.is_some_and(|at| at >= date.and_hms_opt(10, 15, 0).unwrap())));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report
|
report
|
||||||
.process_events
|
.process_events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| event.kind == ProcessEventKind::Trade)
|
.filter(|event| event.kind == ProcessEventKind::Trade)
|
||||||
.count(),
|
.count(),
|
||||||
3
|
2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3093,6 +3145,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -3146,6 +3199,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3210,6 +3264,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -3263,6 +3318,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3365,6 +3421,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3375,6 +3432,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3432,6 +3490,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
|
target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -3558,6 +3617,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3568,6 +3628,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
|||||||
pe_ttm: 10.0,
|
pe_ttm: 10.0,
|
||||||
turnover_ratio: Some(1.0),
|
turnover_ratio: Some(1.0),
|
||||||
effective_turnover_ratio: Some(1.0),
|
effective_turnover_ratio: Some(1.0),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3626,6 +3687,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([
|
target_weights: BTreeMap::from([
|
||||||
("000001.SZ".to_string(), 0.5),
|
("000001.SZ".to_string(), 0.5),
|
||||||
@@ -3744,6 +3806,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3754,6 +3817,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3808,6 +3872,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([
|
target_weights: BTreeMap::from([
|
||||||
("000001.SZ".to_string(), 0.2),
|
("000001.SZ".to_string(), 0.2),
|
||||||
@@ -3926,6 +3991,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -3936,6 +4002,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3990,6 +4057,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: true,
|
rebalance: true,
|
||||||
target_weights: BTreeMap::from([
|
target_weights: BTreeMap::from([
|
||||||
("000001.SZ".to_string(), 0.48),
|
("000001.SZ".to_string(), 0.48),
|
||||||
@@ -4062,6 +4130,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
|||||||
pe_ttm: 20.0,
|
pe_ttm: 20.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4103,6 +4172,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4166,6 +4236,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
|||||||
pe_ttm: 20.0,
|
pe_ttm: 20.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4207,6 +4278,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4272,6 +4344,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -4312,6 +4385,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4386,6 +4460,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -4444,6 +4519,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4526,6 +4602,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -4587,6 +4664,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4640,6 +4718,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4686,6 +4765,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4778,6 +4858,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
DailyFactorSnapshot {
|
DailyFactorSnapshot {
|
||||||
@@ -4788,6 +4869,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -4859,6 +4941,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -4899,6 +4982,7 @@ fn broker_expires_day_limit_buy_at_market_close() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5825,6 +5909,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5863,6 +5948,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5907,6 +5993,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
|||||||
&mut value_portfolio,
|
&mut value_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5932,6 +6019,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
|
|||||||
&mut percent_portfolio,
|
&mut percent_portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5968,6 +6056,7 @@ fn broker_cancels_open_order_by_order_id() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -5991,6 +6080,7 @@ fn broker_cancels_open_order_by_order_id() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -6038,6 +6128,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
@@ -6104,6 +6195,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
|||||||
pe_ttm: 15.0,
|
pe_ttm: 15.0,
|
||||||
turnover_ratio: Some(2.0),
|
turnover_ratio: Some(2.0),
|
||||||
effective_turnover_ratio: Some(1.8),
|
effective_turnover_ratio: Some(1.8),
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: BTreeMap::new(),
|
extra_factors: BTreeMap::new(),
|
||||||
}],
|
}],
|
||||||
vec![CandidateEligibility {
|
vec![CandidateEligibility {
|
||||||
@@ -6145,6 +6237,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
|
|||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&data,
|
&data,
|
||||||
&StrategyDecision {
|
&StrategyDecision {
|
||||||
|
buy_denials: Default::default(),
|
||||||
rebalance: false,
|
rebalance: false,
|
||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
|
|||||||
@@ -52,9 +52,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut quotes = Vec::with_capacity(day_count * bars_per_day);
|
let mut quotes = Vec::with_capacity(day_count * bars_per_day);
|
||||||
for date in &dates {
|
for date in &dates {
|
||||||
let session_start = date
|
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
|
||||||
.and_hms_opt(9, 30, 0)
|
|
||||||
.expect("valid session start");
|
|
||||||
for offset in 0..bars_per_day {
|
for offset in 0..bars_per_day {
|
||||||
let timestamp = session_start + Duration::minutes(offset as i64);
|
let timestamp = session_start + Duration::minutes(offset as i64);
|
||||||
quotes.push(IntradayExecutionQuote {
|
quotes.push(IntradayExecutionQuote {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ fn factor(
|
|||||||
pe_ttm: 18.0,
|
pe_ttm: 18.0,
|
||||||
turnover_ratio: None,
|
turnover_ratio: None,
|
||||||
effective_turnover_ratio: None,
|
effective_turnover_ratio: None,
|
||||||
|
adjustment_factor_backward1: None,
|
||||||
extra_factors: Default::default(),
|
extra_factors: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "fidc-signal-client"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fidc-core = { path = "../fidc-core" }
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Shared signal transport for FIDC backtest and trading services.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use fidc_core::signal_contract::{SignalBookReference,ValidatedSignalBook,cached_signal_book,register_signal_book};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::{Value,json};
|
||||||
|
|
||||||
|
#[derive(Clone,Copy)]
|
||||||
|
pub enum Purpose { Backtest, Online }
|
||||||
|
|
||||||
|
pub async fn load(client:&Client, source_url:&str, token:&str, reference:&SignalBookReference, purpose:Purpose)
|
||||||
|
-> Result<Arc<ValidatedSignalBook>,String>
|
||||||
|
{
|
||||||
|
reference.validate()?;
|
||||||
|
if token.len()<32 {return Err("signal_service_auth_not_configured".into());}
|
||||||
|
let purpose_name=match purpose {Purpose::Backtest=>"backtest",Purpose::Online=>"online"};
|
||||||
|
let payload=json!({"reference":reference,"purpose":purpose_name});
|
||||||
|
let root=format!("{}/api/strategy-signals/internal",source_url.trim_end_matches('/'));
|
||||||
|
// Registration/purpose validation always precedes a process-cache hit.
|
||||||
|
let response=client.post(format!("{root}/validate"))
|
||||||
|
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||||
|
.map_err(|_|"signal_validation_service_unavailable")?;
|
||||||
|
if !response.status().is_success() {return Err(format!("signal_validation_rejected_http_{}",response.status()));}
|
||||||
|
let validation:Value=response.json().await.map_err(|_|"signal_validation_response_invalid")?;
|
||||||
|
if validation.get("ok")!=Some(&Value::Bool(true)) || validation.get("reference")!=Some(&json!(reference)) {
|
||||||
|
return Err("signal_validation_identity_mismatch".into());
|
||||||
|
}
|
||||||
|
let book=if let Some(book)=cached_signal_book(reference)? {book} else {
|
||||||
|
let mut response=client.post(format!("{root}/book"))
|
||||||
|
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
|
||||||
|
.map_err(|_|"signal_book_service_unavailable")?;
|
||||||
|
if !response.status().is_success() {return Err(format!("signal_book_rejected_http_{}",response.status()));}
|
||||||
|
if response.content_length().is_some_and(|bytes|bytes>64*1024*1024) {return Err("signal_book_transport_size_exceeded".into());}
|
||||||
|
let mut bytes=Vec::new();
|
||||||
|
while let Some(chunk)=response.chunk().await.map_err(|_|"signal_book_transport_incomplete")? {
|
||||||
|
if bytes.len().saturating_add(chunk.len())>64*1024*1024 {return Err("signal_book_transport_size_exceeded".into());}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
register_signal_book(reference,&bytes)?
|
||||||
|
};
|
||||||
|
if matches!(purpose,Purpose::Online) {book.require_observed()?;}
|
||||||
|
Ok(book)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 策略级自动交易保护
|
||||||
|
|
||||||
|
## 统一合同
|
||||||
|
|
||||||
|
`runtimeExpressions.trading.automaticTradeProtection` 是每个股票/ETF策略自己的不可变配置。股票池、表达式轮动和显式订单复用 `holding_policy` 内核,不新增全局共享配置,也不修改未配置的历史策略。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":"2026-09-16"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 周期为空、null或0关闭,必须为0—3650整数;锁定支持同股多个区间,起止日包含当日,截止null持续有效。
|
||||||
|
- 买入保护禁止自动减仓/清仓及止盈止损;卖出冷却禁止自动增加仓位。只有实际成交计时,部分成交延长对应最后成交日;未成交、拒绝、撤单不启动。
|
||||||
|
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
|
||||||
|
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
|
||||||
|
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
|
||||||
|
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
|
||||||
|
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
|
||||||
|
|
||||||
|
## 根因补充修复
|
||||||
|
|
||||||
|
组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。
|
||||||
|
|
||||||
|
## 验证与边界
|
||||||
|
|
||||||
|
原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 完成日线形态与次日信号
|
||||||
|
|
||||||
|
`fidc_daily_ohlcv_pattern_v1` 由 `fidc-core::daily_patterns` 单一计算核实现。Source Lake 只读取、核验及传输真实 OHLCV;研究服务和策略表达式不分别维护数值算法。
|
||||||
|
|
||||||
|
四种量价条件为趋势强势、前高突破、放量上涨、缩量突破;额外提供独立的均线下方、放量下跌卖出条件。前三者名称不暗示当日金叉或价格突破等未实际检验的事实。
|
||||||
|
|
||||||
|
## 应用阶段
|
||||||
|
|
||||||
|
- `filter.stock_expr(pattern_signal("<模板 JSON>"))`:选择候选,再按既有顺序和 Top N 取目标。
|
||||||
|
- `filter.buy_expr(pattern_signal("<模板 JSON>"))`:只限制正向仓位增量,不移除目标、不反向清仓,正常减仓不受影响。
|
||||||
|
- `risk.stop_loss(pattern_signal("<独立卖出模板 JSON>"))`:独立退出条件,不使用买入条件的反值。
|
||||||
|
- `pattern_score` 只可用于已通过形态条件的对象;没有放量参照或合法排除对象不伪造零分。
|
||||||
|
|
||||||
|
参数是 JSON 字符串,例如 `pattern_signal("{\"template\":\"ma_below\",\"parameters\":{\"ma_window\":20}}")`。
|
||||||
|
|
||||||
|
新规则必须显式 `execution.matching_type("next_bar_open")`。信号日 D 的完整日线不能用于 D 日盘前或盘中;历史回放按 D 决策、下一真实交易日执行,实时上下文使用已完成 D 日窗口。实际委托仍需要执行日行情、资金、可卖数量、交易许可和风控。不得用研究结果开启交易路由。
|
||||||
|
|
||||||
|
## 数据与预热
|
||||||
|
|
||||||
|
所有价格统一用真实 backward1 因子,成交量不复权。缺失、非有限值、无效 OHLC、重复、未来行、未声明停牌状态均拒绝。仅按明确上市日期证明的上市前窗口或正式停牌记录可以返回结构化排除;不补价、不跳过日期压缩窗口。有效价格但缺复权因子即使停牌也报错。回测和运行态须从表达式提取真实窗口需求,冻结完整日历预热。
|
||||||
|
|
||||||
|
研究选择的范围及日期、上市/停牌排除证据、源查询和哈希需保留。固定候选的后续规则回测不等于历史全市场动态选股。CAPM 全区间拟合属于解释性诊断;要成为次日条件,必须另行使用截至 D 日的滚动估计并验证样本外表现,不得回填到拟合区间内。
|
||||||
|
|
||||||
|
旧任务默认撮合、历史筛选记录和策略源码不变;用户显式创建新规则后才采用此合同。
|
||||||
@@ -0,0 +1,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,54 @@
|
|||||||
|
# 逐成交腿价格风控验收
|
||||||
|
|
||||||
|
## 修复范围
|
||||||
|
|
||||||
|
Engine `7e0877b5860d8724da1c4507a1d1ba393b3497f5`,Trading `1f7bc074024191cfaa5975546f22c2c2c733602a`,均以 tag `v2026.9.11.2` 发布177。
|
||||||
|
|
||||||
|
- 回测在每条实际报价进入撮合前检查原始参考价,滑点和限价处理后再次检查最终价。买入一元股、买入涨停、卖出跌停以及无效价格均按本腿价格处理,不能只依赖最初下单的日线标记或价格。
|
||||||
|
- Paper和Live的订单前检查与Paper撮合共用`MarketSnapshot::execution_reference_price`:普通买入用卖一、卖出用买一;未提供该侧价格时保留既有最新价合同,显式0或负数不当缺失处理、不回退。
|
||||||
|
- 选股仍独立使用其日线最新价与显式规则,不被买卖盘差异改写。盘后固定价仍使用原正式收盘价合同。
|
||||||
|
- Paper已接受/部分成交订单在新报价到达时重新检查。后续被风控拒绝不删除或回滚此前真实模拟成交,不重复扣资金或手续费。
|
||||||
|
- 实盘这里只验证发单前路径;券商实际成交事实必须原样保存,不能声称本地检查能保证委托进入券商后市场不再变化。本轮未提交证券订单。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 原始报价0.9、正向滑点20%后为1.08,仍不得利用滑点绕过一元股规则。
|
||||||
|
- 先出现0.9、后出现1.2的报价,只允许在后一个实际时点成交;不回写到前一时点。
|
||||||
|
- 限价滑点将最终执行价变为0.9时仍拒绝;显式关闭一元股买入规则后放行;卖出不继承买入一元股规则。
|
||||||
|
- 最后价10而卖一11触及涨停:买入拒绝。最后价11而卖一10.5:执行检查不按旧最后价误拒;显式选股涨停规则仍可按最后价拒绝。
|
||||||
|
- 最后价10而买一9触及跌停:卖出拒绝。最后价9而买一9.5:执行检查不按旧最后价误拒。
|
||||||
|
- 原挂单/部分成交后,最后价1.1但卖一0.9:余单拒绝,既有成交数和现金保持不变。
|
||||||
|
|
||||||
|
177测试:Engine 667通过/8忽略,Trading工作区548通过/10忽略,Runner370通过/3忽略,API99通过/1忽略。新场景使用隔离合成账户/报价,未以此冒充原始市场样本。
|
||||||
|
|
||||||
|
## 真实分钟回放
|
||||||
|
|
||||||
|
- 同一冻结请求、信号及bundle,2025-01-03至2025-01-06,分钟13:07,初始10,000,000,滑点0.002,佣金万三/最低5,分钟25%量约束不改。
|
||||||
|
- 原基准 `btr_1789074235759_2081201_1`。
|
||||||
|
- 新运行 `btr_1789093974375_2601124_0`。
|
||||||
|
- 均21成交、11个最终持仓,最终资产9,968,551.588547;订单、成交、账户、权益、持仓和风险审计六项canonical完全一致。
|
||||||
|
- 总SHA `a1aa004f544b34eae0ade41e849a0fd067e39600d1c4ad1a127f5a3d6a79be11`。
|
||||||
|
- 服务端3.490秒,客户端提交/读取/轮询16.927秒。未采集客户端各子段,不能把差值归因到某个具体服务,也不与原报告“提交后轮询耗时”混比。缓存条件的短样本不能外推冷态或多年性能。
|
||||||
|
|
||||||
|
## 发布与状态
|
||||||
|
|
||||||
|
通过官方Backtest和Trading installer构建和发布,没有调用Source/因子重启入口。Backtest service源码仍`75202cc3b876daf99d0d2dffb988ca456c34aabf`并重新链接上述engine。运行二进制SHA与清单一致。
|
||||||
|
|
||||||
|
本轮发布前10:27已观测到3Paper/0Live,重复读取确认;这不同于上一轮的3Paper/1Live,不是本次发布删除。本轮没有新建、恢复或删除实例。发布后仍3Paper/0Live,完整配置/状态摘要与本轮发布前相同。
|
||||||
|
|
||||||
|
Source主PID2267019和因子主PID2178403、NRestarts不变。发布后样本Paper9行、Live11行无WARN/ERROR,Runtime0行不能视为实际执行成功;行情`/readyz`仍503,THS -4302配额问题未恢复。
|
||||||
|
|
||||||
|
## 未完成
|
||||||
|
|
||||||
|
next-open全天量容量和动态滑点使用全天high/low/volume的问题没有被本次修复覆盖,仍按P0时点问题处理。新的执行观察规格位于`/Users/boris/WorkSpace/docs/fidc/execution-observation-prd-20260911.md`,只是后续实现规格,不是已部署能力。禁止静默改用昨量、自动关闭风控、修改旧结果或把后续一分钟量回填到开盘。
|
||||||
|
|
||||||
|
自然Paper/Live还需要合格模型、正式审批和真实可用行情;不开放2026封存,不替研究模型审批。当前实盘列表为空,不自行补建。
|
||||||
|
|
||||||
|
## 证据
|
||||||
|
|
||||||
|
`/srv/fidc/canonical/run/research/execution-leg-risk-20260911/`:
|
||||||
|
|
||||||
|
- `engine-focused.log`、`engine-full.log`、`trading-full.log`、`backtest-full.log`。
|
||||||
|
- `minute-replay/request.json`、`submission.json`、`result.json`、`comparison.json`。
|
||||||
|
- `deploy-before.json`、`deploy-after.json`、`running-binary-verification.json`、`post-deploy-log-audit.json`。
|
||||||
|
- 官方部署日志、研究审计脚本与执行观察设计稿。不改旧证据目录和WFT V18制品。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 执行价风控与共享信号账户隔离验收
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
本次修复已通过测试并发布 177。只证明一元股请求阶段价格修复、同一共享信号的账户隔离和既有真实样本结果不变;完整生产闭环尚未完成。next-open 全天容量、动态滑点的日内可见性及逐成交腿风控仍是未关闭项,不能称为全部成交无未来信息。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- `risk_control.rs` 的 Buy 一元股规则改用本次 `check_price`,不再读取日线 `is_one_yuan` 或当天更早的 `day_open`。无效价格拒绝,其他缺失风险事实仍拒绝;显式 Selection 规则保留。
|
||||||
|
- Trading 共用 `risk.rs` 的 Paper/Live 订单前检查使用新鲜 `last_price`,不再被日线标记或开盘价覆盖。选股阶段的开关和日线标记单独处理。
|
||||||
|
- 缺执行价格继续输出具体 `missing_execution_price field=open`,保留 `historical_price_fallback=false`;停牌等权威状态仍优先,不因新通用校验丢失根因。
|
||||||
|
- 没有修改共享信号内容、模型、账号权限、运行配置、Source Lake、研究 checkpoint 或既有回测数据。
|
||||||
|
|
||||||
|
## 账户隔离组合
|
||||||
|
|
||||||
|
隔离共享核心测试使用同一份经过原生校验的 `fidc.signal-book/v2`,信号只表达保留 50% 持仓。当前价 10、止损 10%、止盈 20%;每个账户独立计算实际订单。
|
||||||
|
|
||||||
|
| 原数量 | 买入价 | 买入费用总额 | 预期剩余 | 结果 |
|
||||||
|
| ---: | ---: | ---: | ---: | --- |
|
||||||
|
| 1,000 | 8.00 | 0 | 0 | 止盈优先于半仓目标 |
|
||||||
|
| 1,000 | 10.00 | 0 | 500 | 按本账户数量减半 |
|
||||||
|
| 3,000 | 10.00 | 0 | 1,500 | 不共用其他账户数量 |
|
||||||
|
| 1,000 | 12.00 | 0 | 0 | 止损 |
|
||||||
|
| 1,000 | 11.11 | 0 | 500 | 尚未跨过止损阈值 |
|
||||||
|
| 1,000 | 11.11 | 2.00 | 0 | 含费用成本跨过止损阈值 |
|
||||||
|
|
||||||
|
六种账户卖出后,当天再消费同一买入目标均不得买回;独立未卖出账户可正常买入。同一信号版本不变,策略规划不预先修改持仓。这些是隔离合成账户测试,不是券商委托/成交证据。
|
||||||
|
|
||||||
|
## 回归与真实回放
|
||||||
|
|
||||||
|
- Engine:656 通过,8 个专用测试忽略。
|
||||||
|
- Trading 工作区:537 通过,9 个专用测试忽略。
|
||||||
|
- Backtest Runner:370 通过、3 忽略;API:99 通过、1 忽略。
|
||||||
|
- 一元股专项覆盖真实执行价为 0.9/1.0/1.2、旧标记与新价格相反、缺失其他风险事实、NaN/无效价及开关独立性。
|
||||||
|
|
||||||
|
实际 HTTP 回测使用原始冻结请求、信号和 bundle,未复制结果:
|
||||||
|
|
||||||
|
- 原基准:`btr_req_260d0f3179d40fda5c918d48eba0a239bd335406c82a1cbe`。
|
||||||
|
- 新运行:`btr_1789091571964_2429476_0`。
|
||||||
|
- 区间:2025-02-05 至 2025-02-10;初始资金 10,000,000;目标 10 仓;next-open;滑点 0.002;佣金万三、最低 5。
|
||||||
|
- 两次均 28 成交,最终资产 10,089,448.918844,收益 0.89448918844%。
|
||||||
|
- 订单、成交、账户事件、权益、持仓、风险审计六项摘要相同。
|
||||||
|
- Canonical SHA256:`befa50b3ec5b94adafede459903db7e2542797cf0eefe2de32900afc83ca1481`。
|
||||||
|
- 服务端 3.954 秒,客户端含轮询 6.072 秒。此短区间已有缓存样本不能代表全市场冷态或多年性能。
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
- Engine `d3c36e947894fd220b62ecd6fbfe02473f70bd2c`,tag `v2026.9.11`。
|
||||||
|
- Trading `dfcec36bd1c0f92e73cd30073540eb39dbd02835`,tag `v2026.9.11`。
|
||||||
|
- Backtest service `75202cc3b876daf99d0d2dffb988ca456c34aabf`,重新链接上述引擎。
|
||||||
|
- 只使用官方 installer,以 Boris 构建和运行。发布后 Backtest/Runtime/Paper/Live 的进程和 HTTP `/healthz` 正常,运行二进制核对独立清单,不仅检查源码 HEAD。实时行情 `/readyz` 仍为 503,原因如下,不能宣称自然交易正常。
|
||||||
|
- 原 3 Paper / 1 Live 配置和状态摘要前后相同;本次投影 Paper 为 `52a909117fe8f01ae35a327bd86310e2583d291609bba6596dc0f49a2b10559c`,Live 为 `aaec1e9dbc984012e9fe677e54db1efb860edd59d5840d1dc87c4b230b26bac6`。仅与本次相同投影的发布前数据对比,不与此前其他字段投影混比。
|
||||||
|
- Source PID 2267019、因子主进程 PID 2178403、NRestarts 均不变。本轮未调用 Source/因子重启入口;不能由主 PID 不变推断全部因子子任务已经验收。
|
||||||
|
|
||||||
|
## 未关闭问题
|
||||||
|
|
||||||
|
### 实时行情配额
|
||||||
|
|
||||||
|
发布后文件日志审查发现 THS `-4302`:本周行情用量超过 1.5 亿。受保护的行情源目录只返回 `ths_realtime`,enabled=true、ready=false;没有已配置可用的授权备用源。行情 `/readyz` 返回503、snapshot_count=0,实盘日志反复记录实际执行日2026-09-11请求150证券、收到0新鲜行情,因此 next-open 规划失败。
|
||||||
|
|
||||||
|
所查尾部8,000行日志中,配额告警最早已出现在01:30:12 UTC(上海09:30),早于本轮09:58的Trading发布。不能把该故障归因于本次一元股代码或用重启解决。不得拿昨日收盘、Source历史数据或手工报价代替实时价格;恢复账户配额或配置正式授权的可用行情源后,才能继续自然交易验收。
|
||||||
|
|
||||||
|
Paper的3条WARN为启动重建的PG读取,分别约1.015/1.122/1.460秒;本轮未见ERROR,但这只是采样范围,不能称全部日志无异常。证据:`realtime-quota-timeline.json`、`realtime-provider-readiness.json`、`post-deploy-file-log-audit.json`。Runtime无新采样日志不等于实际调度通过。
|
||||||
|
|
||||||
|
### 执行容量与校准
|
||||||
|
|
||||||
|
独立依赖探针确认:保持 next-open 订单和开盘价不变,仅修改执行日后来形成的全天量,成交量从 100 变为 1,000;仅修改全天 high/low,动态滑点成交价从 10.305 变为 11.000。探针是合成输入,不冒充市场证据。
|
||||||
|
|
||||||
|
详见 `/Users/boris/WorkSpace/docs/fidc/execution-time-capacity-coordination-20260911.md`。下一步须分离实测执行时点容量和声明的容量估计、冻结校准数据时钟、覆盖挂单逐成交腿;不能偷偷改为昨日成交量、关闭限制或使用未来一分钟量。V18 研究只允许新不可变后继评估,不能改现有结果。
|
||||||
|
|
||||||
|
自然 Paper 观察与正式 Live 仍需真实合格版本和正式审批。当前研究控制模型仅 23 个验证日,2026 留出期继续封存;不得为演示闭环降低门槛、伪造 observed、代替审批或手工发证券订单。
|
||||||
|
|
||||||
|
### 并发代码合并
|
||||||
|
|
||||||
|
报告推送时远端新增 `33924b1/f2e228e` 的策略自动交易保护。已保留并合并至main `f7d16fb`,177源码同步,组合引擎回归666通过、8忽略。该合并后的新保护尚未由本任务部署,线上仍使用本报告列出的d3c36e9/dfcec36清单;不能把源码同步当作发布或把对方功能归为本次已完成的自然交易验收。
|
||||||
|
|
||||||
|
随后 Trading main 新增 `4ff7ee9aeefa2e1013098212dc75b4969499a1a6`,本机与177均已正常快进同步,合并组合工作区545通过、10忽略。此为并发功能合并后的源码测试,同样不改变本次发布清单;不重复部署另一个任务尚在验收的完整交易保护功能。
|
||||||
|
|
||||||
|
## 证据
|
||||||
|
|
||||||
|
177 根目录:`/srv/fidc/canonical/run/research/execution-risk-signal-audit-20260911/`。
|
||||||
|
|
||||||
|
- `engine-full-tests-v2.log`、`trading-full-tests.log`、`backtest-full-tests.log`。
|
||||||
|
- `deploy-before.json`、`deploy-after.json`、`running-binary-verification.json`。
|
||||||
|
- `same-signal-backtest/request.json`、`submission.json`、`result.json`、`comparison.json`。
|
||||||
|
- `execution-time-dependency-probe.json` SHA256:`feeb69275b8ad537f16e4c119cc7e59dd3a15773334fb591e27d7afdf34311d3`。
|
||||||
|
- 官方部署日志与独立探针源码保存在相同证据根,不写入交易数据库或修改原始行情。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user