Compare commits

..

66 Commits

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