Compare commits

..

117 Commits

Author SHA1 Message Date
boris 237ee15a51 修复日内时钟提前记账并按原订单续执行算法单 2026-09-14 03:52:52 +08:00
boris 3a3091a2cf docs(perf): record small selection CPU savings and full shared-input parity 2026-09-14 03:05:55 +08:00
boris d2aa16a2f0 perf(risk): avoid per-symbol selection checks when the frozen policy has none 2026-09-14 02:18:24 +08:00
boris 0576cf9b6d 记录日内晚窗口提前影响早间持仓的隔离反例 2026-09-14 02:08:20 +08:00
boris 636e0dfd05 记录卖出回报续买修复的真实回放与配套发布 2026-09-14 00:10:58 +08:00
boris c98bcc3eb2 修复股票池卖单回报后未继续执行买入阶段 2026-09-13 23:43:26 +08:00
boris 53af3a6a85 perf(data): support exact reservation for known numeric field additions 2026-09-13 22:12:40 +08:00
boris 70c6f7e90b fix(data): expose actual snapshot row counts without cloning data 2026-09-13 17:11:06 +08:00
boris 0ed6752a73 perf(engine): deduplicate daily factor names before allocating sorted output 2026-09-13 16:47:16 +08:00
boris 3e8cc63b9a Revert "perf(engine): share immutable daily factor schemas and numeric buffers"
This reverts commit 5d0823c060bfd2a42a3f86a381e874004ab7f6af.
2026-09-13 14:16:07 +08:00
boris be171683c9 Revert "test(engine): retain static schema names across shared factor rows"
This reverts commit ce0dc0a106f0a98230bb9c428537ec086b968273.
2026-09-13 14:16:07 +08:00
boris 0a6fab9038 Revert "perf(engine): keep empty numeric maps on a direct lookup path"
This reverts commit a63dd94045f3a4b95dbfc917d5d8afa5c22f1897.
2026-09-13 14:16:07 +08:00
boris e8abf43cd4 perf(engine): keep empty numeric maps on a direct lookup path 2026-09-13 13:49:20 +08:00
boris 2286bfa757 test(engine): retain static schema names across shared factor rows 2026-09-13 13:07:52 +08:00
boris 93809eea1b perf(engine): share immutable daily factor schemas and numeric buffers 2026-09-13 13:02:17 +08:00
boris f7f0ff2951 Merge remote-tracking branch 'origin/main' 2026-09-13 11:37:38 +08:00
boris effa0c6456 test(engine): validate quote demand across different account capital 2026-09-13 11:21:09 +08:00
boris b1ca2dfada fix(engine): resolve decision quote scope from the actual run context 2026-09-13 11:18:39 +08:00
boris d15abc18ae test(engine): reproduce account-sensitive quote scope bypass 2026-09-13 11:07:57 +08:00
boris 9370dfe6e9 Merge remote-tracking branch 'origin/main' 2026-09-13 11:06:22 +08:00
boris b19108558f 统一仓位调整时点并保留策略择时与退出意图 2026-09-13 11:05:12 +08:00
boris fe05384f80 test(data): compare compact names by value and allocation class 2026-09-13 09:57:47 +08:00
boris 0ff90c4329 refactor(data): use explicit string views for compact numeric names 2026-09-13 09:55:16 +08:00
boris c85daae608 perf(data): inline numeric factor keys and preserve borrowed static names 2026-09-13 09:53:06 +08:00
boris f73513e2d4 Revert "perf(data): sort compact market-cap keys without revisiting wide snapshots"
This reverts commit 4e953b6e98.
2026-09-13 07:33:05 +08:00
boris 4e953b6e98 perf(data): sort compact market-cap keys without revisiting wide snapshots 2026-09-13 07:12:42 +08:00
boris b232847e40 docs(strategy): record feature-date gap reproduction and replay acceptance 2026-09-13 06:34:22 +08:00
boris e3b3929578 test(strategy): supply declared historical prices in next-open fixture 2026-09-13 06:09:49 +08:00
boris 20e73d567b fix(strategy): require exact feature-date market snapshots 2026-09-13 06:06:18 +08:00
boris cf4498668b test(strategy): qualify the expected missing-market error 2026-09-13 06:03:54 +08:00
boris 3f39943ee4 test(strategy): reject execution-day fallback for missing decision-day market 2026-09-13 05:59:40 +08:00
boris 5c65e65c6f perf(data): parallelize bounded daily symbol indices 2026-09-13 03:25:37 +08:00
boris f3c70ea566 docs(perf): record expression context CPU and real HTTP parity evidence 2026-09-13 01:13:44 +08:00
boris 07b7b181b6 perf(strategy): retain compact factor values in expression contexts 2026-09-13 00:50:07 +08:00
boris fe7243bbc3 perf(data): normalize owned daily bundles in parallel with stable errors 2026-09-12 19:30:28 +08:00
boris 875e31f71f fix(stock-pool): separate cumulative condition facts from quote capacity 2026-09-12 18:50:12 +08:00
boris 61bd14d001 fix(backtest): inspect the authoritative strategy inside runner bundles 2026-09-12 16:55:16 +08:00
boris 531df39911 chore(test): remove obsolete tree-map import after compact snapshot merge 2026-09-12 16:29:56 +08:00
boris e912e2a6f5 Merge remote-tracking branch 'origin/main' 2026-09-12 16:26:06 +08:00
boris b1f2fcb85c fix(stock-pool): scan decoded native conditions instead of serialized source echoes 2026-09-12 16:23:18 +08:00
boris 828690a51e perf(data): compact immutable snapshots with current execution rules 2026-09-12 16:02:24 +08:00
boris ffd23b9920 fix(stock-pool): preserve exit roles and freeze relative reduction bases 2026-09-12 15:54:33 +08:00
boris 4ac9ee5058 fix(stock-pool): keep full stops stronger than simultaneous reductions 2026-09-12 14:21:33 +08:00
boris 848c1a514a fix(stock-pool): separate exit ownership and ordinary quote dependencies 2026-09-12 12:18:26 +08:00
boris 099759ae67 fix(stock-pool): execute verified ETF daily fallbacks with frozen next-open targets 2026-09-12 10:47:47 +08:00
boris d646ca455d merge: integrate causal capacity model with current order clocks and intent planning 2026-09-12 08:52:37 +08:00
boris 3b2a97fa84 fix(backtest): keep next-open limit remainders active on the execution day 2026-09-12 08:02:41 +08:00
boris 2f2258f208 feat(stock-pool): add explicit index-mapped market-cap bands 2026-09-12 07:29:10 +08:00
boris 32a34fadd6 fix(backtest): preserve order origin clocks and emit only state transitions 2026-09-12 07:00:47 +08:00
boris 7ac87a90c4 test: keep snapshot price-risk fixtures in their explicit daily model 2026-09-12 06:51:33 +08:00
boris 5949d4cc69 refactor: remove retired daily quota parameters from target diagnostics 2026-09-12 06:48:07 +08:00
boris 24cb4805a7 fix: keep decision estimates independent and use timed capacity in fill fixtures 2026-09-12 06:46:49 +08:00
boris 053f880e34 test: declare historical audit assumptions in non-liquidity fixtures 2026-09-12 06:39:34 +08:00
boris 4d3a9e0e5b fix: separate historical session capacity audits from execution sizing 2026-09-12 06:32:10 +08:00
boris 4edc70c4c6 fix(backtest): advance resting limit orders on subsequent quote events 2026-09-12 05:05:39 +08:00
boris 9804851133 fix(stock-pool): reconcile completed entry quantities before repricing 2026-09-12 04:29:40 +08:00
boris 6ffa0346aa feat(stock-pool): unify target execution, durable intent state and ETF rules 2026-09-12 03:55:00 +08:00
boris 29eafc79e2 fix(backtest): 停牌证券保留预算且不参与退出权重分配 2026-09-11 22:50:27 +08:00
boris b6859a3360 统一 FIDC FiPanda 品牌标识与资源 2026-09-11 20:17:51 +08:00
boris a00dea58b2 test: assert causal TWAP entitlement and unfilled remainder 2026-09-11 17:11:53 +08:00
boris 2445dc925a fix: size TWAP slices from frozen clocks instead of future quotes 2026-09-11 17:04:25 +08:00
boris 1e8d38f2ee Merge remote-tracking branch 'origin/main' 2026-09-11 16:14:55 +08:00
boris d2071e4311 docs: compare execution frameworks and specify remaining causal capacity work 2026-09-11 16:11:57 +08:00
boris 0fba16342f fix: share explicit execution price selection and reject stale-price fallback 2026-09-11 15:44:38 +08:00
boris bd389de57f test: exercise full odd-lot liquidation against the remaining quote quota 2026-09-11 15:28:19 +08:00
boris bfbf898027 test: qualify execution clock regression time types 2026-09-11 15:19:18 +08:00
boris 2b6d031a55 fix: separate as-of quote time from execution clock and retain volume consumption 2026-09-11 15:12:42 +08:00
boris 6160a74d2a 合并主分支最新因子元数据合同 2026-09-11 15:01:29 +08:00
boris 4acecda79d feat: introduce causal capacity primitives and exact participation quotas 2026-09-11 15:00:18 +08:00
boris d847cb5c28 修正回撤指标的初始净值基线并补充回归测试 2026-09-11 15:00:02 +08:00
boris fa0b316a8b refactor: separate expression metadata and tests from numerical identity 2026-09-11 13:26:39 +08:00
boris 21786187c9 feat: publish typed native indicator parameter domains 2026-09-11 12:52:28 +08:00
boris e0bed38184 Merge remote-tracking branch 'refs/remotes/highmem177/main' 2026-09-11 12:14:35 +08:00
boris c0b78846d6 fix: preserve frozen stock pool candidate order through execution 2026-09-11 12:14:33 +08:00
boris 9d72567b99 test: use the projection module state in calibration failure regression 2026-09-11 12:09:33 +08:00
boris e47228beff fix: reject invalid historical slippage bounds before execution 2026-09-11 11:57:59 +08:00
boris 1fc8a3a9e6 test: enforce causal historical slippage and reject missing calibration 2026-09-11 11:42:21 +08:00
boris 98199c02a2 refactor: isolate historical slippage calibration and propagate pricing errors 2026-09-11 11:35:58 +08:00
boris 6eaa06c1d6 docs: record per-leg price risk release and unchanged minute replay 2026-09-11 10:44:15 +08:00
boris 7e0877b586 fix: validate price risk on every execution leg before and after slippage 2026-09-11 10:21:46 +08:00
boris 36833b7a6a docs: distinguish merged trading tests from published runtime 2026-09-11 10:12:38 +08:00
boris 4c0157b66c docs: record pre-existing realtime quota outage and merged-main test scope 2026-09-11 10:08:40 +08:00
boris f7d16fb664 Merge remote-tracking branch 'origin/main' 2026-09-11 10:03:34 +08:00
boris 97cdfa5972 docs: record execution-price release and open capacity audit blockers 2026-09-11 10:01:37 +08:00
boris f2e228e0a3 合并最新执行风控与策略保护内核 2026-09-11 09:52:51 +08:00
boris 33924b1fba 统一策略成交保护与锁定周期并修正日期条件覆盖 2026-09-11 09:52:04 +08:00
boris d3c36e9478 fix: retain precise missing execution quote diagnostics during risk checks 2026-09-11 09:14:58 +08:00
boris c32807db1d test: distinguish next-open one-yuan execution from later close classification 2026-09-11 09:11:28 +08:00
boris 20d723e2a1 test: inspect broker fill events in shared signal account audit 2026-09-11 09:08:57 +08:00
boris c2b939e818 fix: price one-yuan buy risk at execution and audit account-local signal exits 2026-09-11 09:02:52 +08:00
boris 6684f48f95 test: provide the required benchmark in clock-only dataset fixtures 2026-09-11 05:28:40 +08:00
boris adbfadcc07 test: use canonical local timestamps in signal consumption cases 2026-09-11 05:25:18 +08:00
boris cb6f57be6f fix: bind signal availability to the actual consumption clock 2026-09-11 05:23:33 +08:00
boris e75db2d7b0 test: declare daily market coverage for both minute subscriptions 2026-09-11 04:58:00 +08:00
boris e42dc6938b test: align minute fixtures and missing-data assertions with strict contracts 2026-09-11 04:56:25 +08:00
boris 4f4c1ab7e0 fix: canonicalize all execution limit aliases consistently 2026-09-11 04:48:56 +08:00
boris e549a23c66 统一登记信号引用的策略字段别名 2026-09-11 01:45:46 +08:00
boris 4b21fc4f3f 开放共享策略别名规范化入口供服务统一使用 2026-09-11 01:32:05 +08:00
boris e4bac1cf40 合并最新内核并保留严格数值别名校验 2026-09-11 01:04:27 +08:00
boris 1de96494b3 统一策略数值别名并保留精确冲突校验 2026-09-11 01:03:22 +08:00
boris d9bac529d6 feat: expose frozen signal generator identity to execution clients 2026-09-11 00:04:08 +08:00
boris 3e8d652af1 merge: keep verified dependency lock and canonical v2 signal kernel 2026-09-10 23:50:04 +08:00
boris 23043ee18b build: preserve generated signal client dependency resolution 2026-09-10 23:48:54 +08:00
boris c7c2e69b88 合并已登记信号读取与稳定跨语言数值摘要 2026-09-10 23:30:41 +08:00
boris e6746a7a0e 统一第二版信号簿字段并提供原生校验入口 2026-09-10 23:27:43 +08:00
boris 123467d7ae build: lock shared signal client dependencies 2026-09-10 23:15:59 +08:00
boris b3a3bdbdfd test: provide native signal book identity probe 2026-09-10 23:00:50 +08:00
boris 3f9cff1ee5 merge: retain audited signal identity and cross-language semantic hashing 2026-09-10 22:50:54 +08:00
boris db88abb9e0 feat: share verified signal books across backtest and trading clients 2026-09-10 22:40:59 +08:00
boris 75e5e32281 绑定信号簿内容摘要并按决策日校验日线输入 2026-09-10 22:22:03 +08:00
boris 7d05f8f7c7 为执行报价预取测试补齐明确证券生命周期 2026-09-10 22:18:50 +08:00
boris d01f32ca5b Merge branch 'main' of /tmp/fidc-signal-transfer.2sgDaY/signal.bundle 2026-09-10 22:12:56 +08:00
boris 3dd7b2bd50 fix: distinguish signal consumption and strategy decision schedule dates 2026-09-10 21:20:43 +08:00
boris c8f6ed102c feat: introduce typed account-independent signal execution contract 2026-09-10 21:19:04 +08:00
boris 4664f1a2d3 排除仅作参考的基准并记录周期买入风控拒绝 2026-09-10 20:21:44 +08:00
boris 40481e8825 按日期区分生命周期缺价并保留上市前现金区间 2026-09-10 19:09:21 +08:00
boris 2473cc04bb 为原生因子研究冻结计算内核身份 2026-09-10 16:32:21 +08:00
80 changed files with 27139 additions and 2599 deletions
Generated
+1338 -12
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/fidc-core",
"crates/fidc-signal-client",
]
resolver = "2"
@@ -11,6 +12,7 @@ version = "0.1.0"
authors = ["OpenAI Codex"]
[workspace.dependencies]
rust_decimal = { version = "=1.39.0", features = ["serde-with-str"] }
sha2 = "=0.10.9"
ahash = "=0.8.12"
chrono = { version = "=0.4.44", features = ["serde"] }
+2
View File
@@ -1,3 +1,5 @@
<p><img src="assets/brand/fiPandaWithTitle.png" alt="FiPanda" width="144" height="144"></p>
# fidc-backtest-engine
面向中国 A 股和期货策略的 Rust 回测核心。仓库目标是提供平台自有的策略 DSL、执行模型、撮合模型和结果分析能力,最终由 `fidc-backtest-service` 对外提供策略运行服务。
+11
View File
@@ -0,0 +1,11 @@
# FiPanda 品牌资源
FIDC 系列共用用户提供的两张原始透明图片,不重绘、不裁切、不染色。
- `fiPandaWithTitle.png`:带标题版,登录页、项目首页、独立报告使用。
- `fipanda.png`:无标题版,顶部导航、小尺寸标识和系统图标使用。
- `favicon-32.png`:由无标题版等比生成,供浏览器标签页使用。
导航图标建议 28–42px;带标题版建议 96–160px。始终等比完整显示,保留透明背景,不为 Logo 增加大块容器或改变业务页面主题。项目名称、权限、交易合同与内部 FIDC 标识不因品牌图片变更而重命名。
原图保存在 `omniquant/assets/brand/source/``manifest.json` 记录原图和显示资源的 SHA-256。需要更新时,在完整工作区运行 `node omniquant/scripts/sync-fipanda-branding.mjs`,统一生成八个仓库及可下载策略技能包的资源,禁止分别维护不同版本。
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"schema": "fidc.fipanda-brand/v1",
"brand": "FiPanda",
"sourceRepository": "omniquant",
"sources": [
{
"file": "fipanda.png",
"sha256": "ff7dc0677b7d9c8dc6d008d3391f27fd583e3de35560dab021c1e2f8f2c464e5"
},
{
"file": "fiPandaWithTitle.png",
"sha256": "01bfb6acb36830d9b1ce6f7dc8c718977d43af44572ee6a26991d6c6aa2847d6"
}
],
"assets": [
{
"file": "fipanda.png",
"source": "fipanda.png",
"pixels": 192,
"usage": "navigation, compact icons, apple-touch-icon",
"sha256": "450aad3506ec3c67624b7705f4d8f851a9102fe950e7671a06fb1203dc1d9cf7"
},
{
"file": "fiPandaWithTitle.png",
"source": "fiPandaWithTitle.png",
"pixels": 384,
"usage": "login, project README, standalone reports",
"sha256": "d33846b3687ea69d43b4a1f17ceeb02662bd25f26fabc4c86c25b3475f20dede"
},
{
"file": "favicon-32.png",
"source": "fipanda.png",
"pixels": 32,
"usage": "browser tab icon",
"sha256": "ec3d7783457db284d46ae6e27833685203838637a7d68946ff85402725f8e420"
}
]
}
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
authors.workspace = true
[dependencies]
compact_str = { version = "=0.10.0", features = ["serde"] }
rust_decimal.workspace = true
ahash.workspace = true
chrono.workspace = true
indexmap.workspace = true
@@ -15,6 +15,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
io::stdin().read_to_string(&mut input)?;
let output = if input.trim().is_empty() {
factor_events::catalog()
} else if serde_json::from_str::<Value>(&input)?.get("rank_history").is_some() {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Rank { dates:Vec<chrono::NaiveDate>, universe:Vec<String>, values:std::collections::BTreeMap<String,Vec<Option<f64>>> }
let value:Value=serde_json::from_str(&input)?;
let request:Rank=serde_json::from_value(value["rank_history"].clone())?;
json!({"result":fidc_core::factor_cross_section::rank_history(&request.dates,&request.universe,&request.values)?})
} else {
let request: Request = serde_json::from_str(&input)?;
let results = request
@@ -0,0 +1,15 @@
use std::io::{Read, Write};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut raw=Vec::new();
std::io::stdin().take(64*1024*1024+1).read_to_end(&mut raw)?;
if raw.len()>64*1024*1024 {return Err("signal_book_transport_limit".into());}
let book:fidc_core::signal_contract::SignalBook=serde_json::from_slice(&raw)?;
let version=book.content_sha256()?;
let validated=book.validate()?;
let result=serde_json::json!({"schema":fidc_core::signal_contract::SIGNAL_BOOK_SCHEMA,
"versionSha256":version,"symbols":validated.symbols(),
"onlineAllowed":validated.require_observed().is_ok()});
std::io::stdout().write_all(serde_json::to_string(&result)?.as_bytes())?;
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,677 @@
use super::*;
fn time(minute: u32) -> NaiveTime {
NaiveTime::from_hms_opt(10, minute, 0).unwrap()
}
fn data(quotes: &[(u32, f64, u32)]) -> DataSet {
data_with_snapshot(quotes, limit_test_snapshot())
}
fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet {
DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
vec![],
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
vec![],
quotes
.iter()
.map(|&(minute, price, volume)| {
let mut quote = limit_test_quote(price, price, price);
quote.timestamp = quote.date.and_time(time(minute));
quote.volume_delta = u64::from(volume);
quote.amount_delta = price * f64::from(volume);
quote.bid1_volume = u64::from(volume / 100);
quote.ask1_volume = u64::from(volume / 100);
quote
})
.collect(),
)
.unwrap()
}
fn broker() -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
BrokerSimulator::new(
ChinaAShareCostModel::default()
.with_commission_rate(0.0003)
.with_minimum_commission(5.),
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time(0))
.with_volume_limit(true)
.with_volume_percent(0.25)
.with_liquidity_limit(false)
.with_inactive_limit(false)
.with_strict_value_budget(true)
}
fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision {
StrategyDecision {
order_intents: vec![OrderIntent::AlgoValue {
symbol: "000001.SZ".into(),
value,
style,
start_time: Some(time(0)),
end_time: Some(time(10)),
reason: "clock-algorithm".into(),
}],
..Default::default()
}
}
fn step(
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
portfolio: &mut PortfolioState,
data: &DataSet,
minute: u32,
decision: &StrategyDecision,
) -> BrokerExecutionReport {
broker
.execute_between(
limit_test_snapshot().date,
portfolio,
data,
decision,
Some(time(minute)),
Some(time(minute)),
)
.unwrap()
}
#[test]
fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() {
let data = data(&[
(0, 10., 4_000),
(2, 10.1, 4_000),
(5, 10.2, 4_000),
(10, 10.3, 4_000),
]);
let decision = intent(AlgoOrderStyle::Twap, 10_000.);
let mut synchronous_account = PortfolioState::new(20_000.);
let reference = broker()
.execute(
limit_test_snapshot().date,
&mut synchronous_account,
&data,
&decision,
)
.unwrap();
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let mut fills = Vec::new();
let mut events = Vec::new();
let empty = StrategyDecision::default();
for minute in [0, 2, 5, 10] {
let batch = step(
&broker,
&mut account,
&data,
minute,
if minute == 0 { &decision } else { &empty },
);
assert!(
batch
.fill_events
.iter()
.all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute))
);
fills.extend(batch.fill_events);
events.extend(batch.order_events);
}
let canonical = |rows: &[crate::events::FillEvent]| {
rows.iter()
.map(|fill| {
(
fill.quantity,
fill.price.to_bits(),
fill.commission.to_bits(),
fill.stamp_tax.to_bits(),
fill.transfer_fee.to_bits(),
fill.execution_timestamp,
fill.order_id,
)
})
.collect::<Vec<_>>()
};
assert_eq!(canonical(&fills), canonical(&reference.fill_events));
assert_eq!(account.cash(), synchronous_account.cash());
assert_eq!(fills.iter().map(|fill| fill.quantity).sum::<u32>(), 900);
assert_eq!(fills.iter().map(|fill| fill.commission).sum::<f64>(), 5.);
assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::<f64>() <= 10_000.);
assert!(events.iter().all(|event| event.order_id == Some(1)));
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() {
let data = data(&[
(0, 10., 4_000),
(2, 10., 4_000),
(5, 10., 4_000),
(10, 10., 4_000),
]);
let broker = broker();
let mut account = PortfolioState::new(20_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.));
let partial = step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
assert_eq!(
partial
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
let working = broker.open_order_views();
assert_eq!(working[0].order_id, 1);
assert_eq!(working[0].filled_quantity, 100);
assert_eq!(
working[0].reserved_cash,
Some(10_000. + partial.fill_events[0].net_cash_flow)
);
let cancel = step(
&broker,
&mut account,
&data,
3,
&StrategyDecision {
order_intents: vec![OrderIntent::CancelAll {
reason: "explicit-user-cancel".into(),
}],
..Default::default()
},
);
assert!(cancel.fill_events.is_empty());
assert_eq!(
cancel.order_events.last().unwrap().status,
OrderStatus::Canceled
);
assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100);
assert!(broker.open_order_views().is_empty());
assert!(
step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default()
)
.fill_events
.is_empty()
);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
}
#[test]
fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() {
let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]);
let broker = broker();
let mut account = PortfolioState::new(20_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
assert_eq!(
broker.next_day_order_expiry(limit_test_snapshot().date),
Some(time(10))
);
let terminal = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert!(terminal.fill_events.is_empty());
assert_eq!(
terminal.order_events.last().unwrap().status,
OrderStatus::Expired
);
assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100);
assert!(
terminal
.process_events
.iter()
.any(|event| event.detail.contains("Expired")),
"{:?}",
terminal.process_events
);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() {
let data = data(&[
(0, 10., 4_000),
(1, 10., 4_000),
(2, 10., 4_000),
(10, 10., 4_000),
]);
let broker = broker();
let mut account = PortfolioState::new(11_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
let other = step(
&broker,
&mut account,
&data,
1,
&StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 1_000,
reason: "separate-buy".into(),
}],
..Default::default()
},
);
assert!(
other.fill_events.is_empty(),
"cash reserved for order 1 was spent: {:?}",
other.fill_events
);
let final_batch = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert!(
final_batch
.fill_events
.iter()
.all(|fill| fill.order_id == Some(1))
);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 900);
assert!(account.cash() >= 1_000.);
}
#[test]
fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() {
let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)];
let mut changed = limit_test_snapshot();
changed.close = 100.;
changed.last_price = 100.;
let run = |data: DataSet| {
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let initial = step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
assert!(initial.fill_events.is_empty());
let quantity = broker.open_order_views()[0].requested_quantity;
let final_batch = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
(
quantity,
final_batch
.fill_events
.iter()
.map(|fill| {
(
fill.quantity,
fill.price.to_bits(),
fill.net_cash_flow.to_bits(),
)
})
.collect::<Vec<_>>(),
)
};
assert_eq!(
run(data(&quotes)),
run(data_with_snapshot(&quotes, changed))
);
}
#[test]
fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() {
let data = data(&[
(0, 10., 400),
(2, 10., 800),
(5, 10., 1_200),
(10, 10., 4_000),
]);
let decision = intent(AlgoOrderStyle::Vwap, 10_000.);
let mut synchronous_account = PortfolioState::new(20_000.);
let reference = broker()
.execute(
limit_test_snapshot().date,
&mut synchronous_account,
&data,
&decision,
)
.unwrap();
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let empty = StrategyDecision::default();
let mut filled = 0;
let mut commission = 0.;
for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] {
let batch = step(
&broker,
&mut account,
&data,
minute,
if minute == 0 { &decision } else { &empty },
);
filled += batch
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>();
commission += batch
.fill_events
.iter()
.map(|fill| fill.commission)
.sum::<f64>();
assert_eq!(filled, expected);
assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1)
&& fill.execution_timestamp.unwrap().time() <= time(minute)));
}
assert_eq!(account.cash(), synchronous_account.cash());
assert_eq!(
commission,
reference
.fill_events
.iter()
.map(|fill| fill.commission)
.sum::<f64>()
);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() {
let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]);
let broker = broker().with_matching_type(MatchingType::Vwap);
let mut account = PortfolioState::new(20_000.);
let first = step(
&broker,
&mut account,
&data,
0,
&StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".into(),
quantity: 900,
reason: "configured-vwap".into(),
}],
..Default::default()
},
);
assert_eq!(
first
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
assert_eq!(
broker.open_order_views().len(),
1,
"{:?}",
first.order_events
);
let second = step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
assert_eq!(second.fill_events[0].quantity, 100);
assert_eq!(second.fill_events[0].order_id, Some(1));
let final_batch = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert_eq!(final_batch.fill_events[0].quantity, 700);
assert_eq!(final_batch.fill_events[0].order_id, Some(1));
assert!(broker.open_order_views().is_empty());
}
#[test]
fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() {
let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]);
let date = limit_test_snapshot().date;
for acquired_today in [false, true] {
let broker = broker();
let mut account = PortfolioState::new(20_000.);
account.position_mut("000001.SZ").buy(
if acquired_today {
date
} else {
date.pred_opt().unwrap()
},
1_000,
10.,
);
let decision = intent(AlgoOrderStyle::Vwap, -10_000.);
let mut fills = Vec::new();
let mut events = Vec::new();
let empty = StrategyDecision::default();
for minute in [0, 2, 10] {
let batch = step(
&broker,
&mut account,
&data,
minute,
if minute == 0 { &decision } else { &empty },
);
fills.extend(batch.fill_events);
events.extend(batch.order_events);
}
assert_eq!(
fills.iter().map(|fill| fill.quantity).sum::<u32>(),
if acquired_today { 0 } else { 1_000 }
);
assert!(events.iter().all(|event| event.order_id == Some(1)));
if !acquired_today {
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
assert_eq!(events.last().unwrap().requested_quantity, 1_000);
assert_eq!(events.last().unwrap().filled_quantity, 1_000);
}
assert!(broker.open_order_views().is_empty());
}
}
#[test]
fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() {
let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]);
for tif in [
OrderTimeInForce::Ioc,
OrderTimeInForce::Fok,
OrderTimeInForce::Day,
OrderTimeInForce::Gtc,
] {
let broker = broker();
let mut account = PortfolioState::new(20_000.);
let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.);
if !decision.order_intents[0].supports_time_in_force(tif) {
decision.order_intents = decision
.order_intents
.into_iter()
.map(|intent| intent.with_time_in_force(tif))
.collect();
let error = broker
.execute_between(
limit_test_snapshot().date,
&mut account,
&data,
&decision,
Some(time(0)),
Some(time(0)),
)
.unwrap_err();
assert!(
error
.to_string()
.contains("is not supported for this order intent")
);
assert_eq!(account.cash(), 20_000.);
assert!(broker.open_order_views().is_empty());
continue;
}
decision.order_intents = decision
.order_intents
.into_iter()
.map(|intent| intent.with_time_in_force(tif))
.collect();
let first = step(&broker, &mut account, &data, 0, &decision);
let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc);
assert_eq!(
!broker.open_order_views().is_empty(),
persists,
"{tif:?}: {:?}",
first.order_events
);
if !persists {
assert!(
step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default()
)
.fill_events
.is_empty()
);
}
}
}
#[test]
fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() {
let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]);
let broker = broker();
let mut account = PortfolioState::new(15_000.);
let mut decision = intent(AlgoOrderStyle::Twap, 10_000.);
decision
.order_intents
.extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents);
step(&broker, &mut account, &data, 0, &decision);
assert_eq!(
broker
.open_order_views()
.iter()
.map(|order| order.reserved_cash.unwrap())
.collect::<Vec<_>>(),
vec![10_000., 5_000.]
);
let report = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert_eq!(
report
.fill_events
.iter()
.map(|fill| (fill.order_id, fill.quantity))
.collect::<Vec<_>>(),
vec![(Some(1), 900), (Some(2), 500)]
);
assert!(account.cash() >= 0.);
assert!(broker.open_order_views().is_empty());
}
#[test]
fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]);
let broker = broker()
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut account = PortfolioState::new(20_000.);
step(
&broker,
&mut account,
&data,
0,
&intent(AlgoOrderStyle::Twap, 10_000.),
);
let first = step(
&broker,
&mut account,
&data,
2,
&StrategyDecision::default(),
);
let last = step(
&broker,
&mut account,
&data,
10,
&StrategyDecision::default(),
);
assert_eq!(
first
.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
assert_eq!(
last.fill_events
.iter()
.map(|fill| fill.quantity)
.sum::<u32>(),
100
);
assert_eq!(
last.order_events.last().unwrap().status,
OrderStatus::Expired
);
assert_eq!(last.order_events.last().unwrap().filled_quantity, 200);
assert!(broker.open_order_views().is_empty());
}
+788
View File
@@ -0,0 +1,788 @@
//! Executes one frozen pool intent against real broker-simulator state.
use super::*;
use crate::holding_policy::HoldingLifecycleEvidence;
use crate::stock_pool_execution as pool;
use rust_decimal::{Decimal, prelude::ToPrimitive};
use chrono::Timelike;
#[derive(Debug)]
pub(super) struct DeferredStockPoolExecution {
date: NaiveDate,
contract: Box<pool::FrozenStockPoolIntent>,
buy_only: bool,
symbols: BTreeSet<String>,
initial_holdings: BTreeSet<String>,
}
impl<C, R> BrokerSimulator<C, R> {
pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet<String> {
self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect()
}
pub(crate) fn has_pending_stock_pool_execution(&self) -> bool {
!self.deferred_stock_pools.borrow().is_empty()
}
pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) {
self.deferred_stock_pools.borrow_mut().retain(|_, pending| {
if pending.date <= date {
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
false
} else { true }
});
}
}
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
if !value.is_finite() {
return Err(BacktestError::Execution(format!(
"stock_pool_nonfinite_{label}"
)));
}
value
.to_string()
.parse()
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
}
fn etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) {
report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None,
symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail});
}
fn pool_positions(
portfolio: &PortfolioState,
date: NaiveDate,
) -> Result<Vec<pool::Position>, BacktestError> {
portfolio
.positions()
.values()
.filter(|p| p.quantity > 0)
.map(|p| {
Ok(pool::Position {
symbol: p.symbol.clone(),
quantity: Decimal::from(p.quantity),
closable_quantity: Decimal::from(p.sellable_qty(date)),
average_cost: decimal(p.average_cost, "position_cost")?,
})
})
.collect()
}
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> {
let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time);
let mut expired = Vec::new();
for (id, pending) in self.deferred_stock_pools.borrow().iter() {
let end = NaiveTime::parse_from_str(&pending.contract.rule.window_end, "%H:%M")
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
if pending.date != date || clock.is_some_and(|clock| clock >= end) { expired.push(id.clone()); }
}
for id in expired {
if let Some(pending) = self.deferred_stock_pools.borrow_mut().remove(&id) {
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
}
}
if self.has_open_orders() || clock.is_none() { return Ok(()); }
let pending = std::mem::take(&mut *self.deferred_stock_pools.borrow_mut());
for (id, pending) in pending {
let now = clock.expect("clock checked above");
let start = NaiveTime::parse_from_str(&pending.contract.rule.window_start, "%H:%M")
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
if now < start || !pool::stock_pool_is_trading_minute(now.hour() * 60 + now.minute()) {
self.deferred_stock_pools.borrow_mut().insert(id, pending);
continue;
}
let prior_followup = self.runtime_stock_pool_followup.replace(true);
let prior_decision = self.runtime_decision_date.replace(Some(pending.contract.signal_date));
let prior_created = self.runtime_order_created_date.replace(Some(date));
let order_start = report.order_events.len();
let fill_start = report.fill_events.len();
report.diagnostics.push(format!("stock_pool_resume_after_order_reports generation={} clock={} cash={}",pending.contract.generation,clock.unwrap(),portfolio.cash()));
let result = self.process_stock_pool_contract_phase(date, portfolio, data, &pending.contract,
&mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor,
&mut session.commission_state, report, pending.buy_only, Some(&pending.initial_holdings));
self.runtime_stock_pool_followup.set(prior_followup);
self.runtime_decision_date.set(prior_decision);
self.runtime_order_created_date.set(prior_created);
result?;
Self::annotate_report_range(report, order_start, fill_start, pending.contract.signal_date, date, date);
}
Ok(())
}
fn pool_quote_inputs(
&self,
date: NaiveDate,
data: &DataSet,
symbols: &BTreeSet<String>,
execution_clock: Option<NaiveDateTime>,
cumulative_conditions: bool,
) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
let mut unavailable = Vec::new();
let quotes = symbols
.iter()
.map(|symbol| {
let snapshot = data.market(date, symbol).ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_execution_snapshot_missing:{symbol}:{date}"
))
})?;
let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?;
let fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?;
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback {
let calibration = self.slippage_calibration(data, snapshot)?;
(reference.price, snapshot.prev_close, None, None, None, None,
self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?,
self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?)
} else if self
.matching_type_uses_intraday_quotes()
{
let time = self
.runtime_intraday_start_time
.get()
.or(self.intraday_execution_start_time)
.ok_or_else(|| {
BacktestError::Execution(
"stock_pool_intraday_execution_clock_required".into(),
)
})?;
let clock = execution_clock
.unwrap_or(date.and_time(time))
.max(date.and_time(time));
let quote = data
.execution_quotes_on(date, symbol)
.iter()
.rev()
.find(|quote| quote.timestamp <= clock)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_execution_quote_missing:{symbol}:{clock}"
))
})?;
if !quote.last_price.is_finite() || quote.last_price <= 0.0 {
return Err(BacktestError::Execution(format!(
"stock_pool_execution_quote_invalid:{symbol}:{clock}"
)));
}
let raw_buy = self
.select_quote_reference_price(
snapshot,
quote,
OrderSide::Buy,
self.matching_type_for_algo_request(None),
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_buy_reference_missing:{symbol}:{clock}"
))
})?;
let raw_sell = self
.select_quote_reference_price(
snapshot,
quote,
OrderSide::Sell,
self.matching_type_for_algo_request(None),
)
.ok_or_else(|| {
BacktestError::Execution(format!(
"stock_pool_sell_reference_missing:{symbol}:{clock}"
))
})?;
let calibration = self.slippage_calibration(data, snapshot)?;
let buy = self.quote_execution_price(
snapshot,
OrderSide::Buy,
raw_buy,
None,
calibration.as_ref(),
)?;
let sell = self.quote_execution_price(
snapshot,
OrderSide::Sell,
raw_sell,
None,
calibration.as_ref(),
)?;
let totals = if cumulative_conditions {
match data.execution_session_totals(symbol, clock) {
Ok(totals) => Some(totals),
Err(reason) => { unavailable.push(reason); None }
}
} else { None };
(
quote.last_price,
snapshot.prev_close,
totals.map(|total| total.0),
totals.map(|total| total.1),
Some(quote.bid1),
Some(quote.ask1),
buy,
sell,
)
} else {
let price = snapshot.price(self.effective_execution_price_field(date));
if !price.is_finite() || price <= 0.0 {
return Err(BacktestError::Execution(format!(
"stock_pool_execution_price_missing:{symbol}:{date}"
)));
}
// A daily open does not reveal the session's volume/turnover.
let completed = self.effective_execution_price_field(date) == PriceField::Close;
let totals = if cumulative_conditions && !completed {
let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap());
match data.execution_session_totals(symbol, at) {
Ok(totals) => Some(totals),
Err(reason) => { unavailable.push(reason); None }
}
} else { None };
let amount = if completed && cumulative_conditions {
data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied()
.map(|value| decimal(value, "amount")).transpose()?
} else { totals.map(|total| total.1) };
(
price,
snapshot.prev_close,
if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) },
amount,
None,
None,
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
)
};
Ok(pool::MarketSnapshot {
symbol: symbol.clone(),
last_price: decimal(price, "price")?,
prev_close: Some(decimal(prev, "prev_close")?),
volume,
turnover: amount,
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
instrument_rules: Some(pool::StockPoolInstrumentRules {
price_tick: decimal(snapshot.price_tick, "price_tick")?,
quantity_step: instrument.order_step_size().into(),
minimum_buy_quantity: instrument.minimum_order_quantity().into(),
}),
buy_sizing_price: Some(decimal(buy_price, "buy_price")?),
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
})
})
.collect::<Result<Vec<_>, BacktestError>>()?;
Ok((quotes, unavailable))
}
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) {
return Ok(None);
}
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time)
.ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?;
let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time));
crate::etf_execution::reference(data, symbol, at).map(Some)
}
pub(super) fn process_stock_pool_contract(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
contract: &pool::FrozenStockPoolIntent,
intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut IntradayExecutionLedger,
global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
self.process_stock_pool_contract_phase(date, portfolio, data, contract, intraday_turnover,
execution_cursors, global_execution_cursor, commission_state, report, false, None)
}
fn process_stock_pool_contract_phase(
&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
contract: &pool::FrozenStockPoolIntent, intraday_turnover: &mut BTreeMap<String, u32>,
execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>, report: &mut BrokerExecutionReport, buy_only: bool,
initial_holdings: Option<&BTreeSet<String>>,
) -> Result<(), BacktestError> {
if contract.signal_date > date
|| contract.frozen_equity < Decimal::ZERO
|| contract.generation.is_empty()
|| contract.pool_id.trim().is_empty()
{
return Err(BacktestError::Execution(
"stock_pool_frozen_intent_invalid".into(),
));
}
if self.matching_type == MatchingType::NextBarOpen && contract.signal_date >= date {
return Err(BacktestError::Execution(
"stock_pool_next_open_requires_prior_signal".into(),
));
}
let mut selection = contract.selection.clone();
let mut members = contract.members.clone();
for symbol in &contract.selection.requested_symbols {
let instrument = data.instruments().get(symbol).ok_or_else(|| {
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
})?;
if portfolio.position(symbol).is_none()
&& let Some(reason) = instrument.dated_market_absence_reason(date)
{
selection.requested_symbols.retain(|v| v != symbol);
selection.normal_trading_symbols.retain(|v| v != symbol);
selection.risk_eligible_symbols.retain(|v| v != symbol);
selection.final_symbols.retain(|v| v != symbol);
members.retain(|v| &v.symbol != symbol);
report.diagnostics.push(format!(
"stock_pool_market_absence symbol={symbol} date={date} reason={reason}"
));
}
}
let mut scope = selection
.requested_symbols
.iter()
.cloned()
.collect::<BTreeSet<_>>();
scope.extend(portfolio.positions().keys().cloned());
let before_positions = initial_holdings.cloned().unwrap_or_else(|| portfolio.positions().keys().cloned().collect());
let official_dates = data.calendar().iter().collect::<Vec<_>>();
let initial_positions = pool_positions(portfolio, date)?;
let state = portfolio
.stock_pool_execution_state(&contract.pool_id)
.observe(
contract.signal_date,
date,
&official_dates,
&members,
&initial_positions,
)
.map_err(BacktestError::Execution)?;
portfolio
.set_stock_pool_execution_state(&contract.pool_id, state)
.map_err(BacktestError::Execution)?;
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
if self.has_open_orders() {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions,
});
report
.diagnostics
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
return Ok(());
}
let mut constraints = contract.constraints.clone();
constraints.execution_date = Some(date);
constraints.frozen_positions.clear();
let mut quote_scope = scope.clone();
for symbol in &scope {
let paused = data.market(date, symbol).is_some_and(|row| row.paused)
|| data
.candidate(date, symbol)
.is_some_and(|row| row.is_paused);
if !paused {
continue;
}
quote_scope.remove(symbol);
if let Some(position) = portfolio
.position(symbol)
.filter(|position| position.quantity > 0)
{
constraints.frozen_positions.insert(
symbol.clone(),
pool::FrozenStockPoolPosition {
trade_date: date,
reason: "paused".into(),
valuation_price: decimal(position.last_price, "paused_holding_valuation")?,
},
);
} else {
selection
.normal_trading_symbols
.retain(|item| item != symbol);
selection
.risk_eligible_symbols
.retain(|item| item != symbol);
selection.final_symbols.retain(|item| item != symbol);
selection
.exclusion_reasons
.entry(symbol.clone())
.or_default()
.push("paused".into());
}
}
// All delayed symbols in a generation share immutable configuration.
// Do not duplicate an N-member pool N times in a large mixed pool.
let mut deferred_configuration = None;
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
if buy_only && side == pool::OrderSide::Sell { continue; }
if side == pool::OrderSide::Buy && self.has_open_orders()
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
});
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
break;
}
let mut fallback_references = BTreeMap::new();
for symbol in &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition };
if !condition.trim().is_empty() {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal")));
}
fallback_references.insert(symbol.clone(), reference);
}
}
let (quotes, unavailable) = self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor,
crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?;
let positions = pool_positions(portfolio, date)?;
let execution_state = portfolio
.stock_pool_execution_state(&contract.pool_id)
.observe(
contract.signal_date,
date,
&official_dates,
&members,
&positions,
)
.map_err(BacktestError::Execution)?;
constraints.pending_entry_symbols = execution_state.pending_symbols();
constraints.prior_target_weights = execution_state.last_target_weights.clone();
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
let account = pool::AccountSnapshot {
total_equity: contract.frozen_equity,
cash: decimal(portfolio.cash(), "cash")?,
frozen_cash: Decimal::ZERO,
};
constraints.automatic_permissions.clear();
if contract.rule.automatic_trade_protection.enabled() {
for symbol in &scope {
let position = portfolio.position(symbol).filter(|p| p.quantity > 0);
let sold = self
.same_day_sold_symbols
.borrow()
.iter()
.rev()
.find(|(day, symbols)| **day <= date && symbols.contains(symbol))
.map(|(day, _)| *day);
let evidence = HoldingLifecycleEvidence {
has_position: position.is_some(),
opened_date: position.and_then(|p| p.opened_date()),
last_buy_date: position.and_then(|p| p.last_buy_date()),
last_sell_date: sold,
};
let permission = contract
.rule
.automatic_trade_protection
.evaluate(symbol, date, &evidence, data.calendar())
.map_err(BacktestError::Execution)?;
constraints
.automatic_permissions
.insert(symbol.clone(), permission);
}
}
if side == pool::OrderSide::Buy {
for (symbol, reference) in &fallback_references {
if !reference.immediate {
// The sell leg was queued, not filled. Keep its real
// holdings/slots and do not finance buys with proceeds
// from the following session.
constraints.automatic_permissions.entry(symbol.clone()).or_default()
.sell_denial.get_or_insert("etf_daily_open_deferred");
}
}
}
if self
.risk_config
.static_rules
.forbid_same_day_rebuy_after_sell
{
constraints.same_day_sold_symbols.extend(
self.same_day_sold_symbols
.borrow()
.get(&date)
.into_iter()
.flatten()
.cloned(),
);
}
constraints.same_day_sold_symbols.extend(
before_positions
.iter()
.filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0))
.cloned(),
);
let fee =
|symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result<Decimal, String> {
let amount = gross
.to_f64()
.ok_or("stock_pool_cost_amount_out_of_range")?;
decimal(
self.cost_model
.calculate_for_instrument(
date,
if side == pool::OrderSide::Buy {
OrderSide::Buy
} else {
OrderSide::Sell
},
amount,
data.instruments().get(symbol),
)
.total(),
"fee",
)
.map_err(|e| e.to_string())
};
let plan = pool::build_stock_pool_target_plan_with_fee_model(
&selection,
&members,
&contract.rule,
&account,
&positions,
&quotes,
contract.invest_ratio_bps,
contract.reserve_cash,
&contract.out_of_pool_policy,
"full_rebalance",
&constraints,
&contract.generation,
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
Some(&fee),
)
.map_err(|error| BacktestError::Execution(if !unavailable.is_empty()
&& (error.contains("requires volume") || error.contains("requires amount")) {
format!("{error}; {}", unavailable.join("; "))
} else { error }))?;
report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}")));
let mut updated = execution_state
.record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?;
for (symbol, reference) in &fallback_references {
if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) {
// The signal only fixes money, not shares at a stale close.
entry.completion_quantity = None;
}
}
portfolio
.set_stock_pool_execution_state(&contract.pool_id, updated)
.map_err(BacktestError::Execution)?;
report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget));
let max_positions = constraints
.target_holding_count
.unwrap_or(selection.final_symbols.len());
for row in plan.rows {
if side == pool::OrderSide::Buy && row.side.is_none() {
report.diagnostics.push(format!(
"stock_pool_decision symbol={} status={} current={} target={} reason={}",
row.symbol,
row.status,
row.current_quantity,
row.target_quantity,
row.reason
));
}
if row.side != Some(side) {
continue;
}
if let Some(reference) = fallback_references.get(&row.symbol) {
let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated");
let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time));
if !reference.immediate {
report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on));
let deferred = deferred_configuration.get_or_insert_with(|| (
std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()),
));
let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into());
etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value));
self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(),
signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on,
target_value:row.target_value, target_weight_bps:row.target_weight_bps, side,
max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1),
reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()),
});
continue;
}
}
if side == pool::OrderSide::Buy
&& portfolio
.position(&row.symbol)
.is_none_or(|p| p.quantity == 0)
&& Self::positive_position_count(portfolio) >= max_positions
{
report.diagnostics.push(format!(
"stock_pool_buy_deferred symbol={} reason=occupied_position_slots",
row.symbol
));
continue;
}
let target = row.target_quantity.to_i32().ok_or_else(|| {
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
})?;
let fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date));
let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target"));
let first_fill = report.fill_events.len();
if fallback_references.contains_key(&row.symbol) {
report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date));
etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date));
}
let mut execute = || if let Some(price) = row.limit_price {
self.process_limit_target_shares(
date,
portfolio,
data,
&row.symbol,
target,
price.to_f64().ok_or_else(|| {
BacktestError::Execution("stock_pool_limit_price_out_of_range".into())
})?,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
} else {
self.process_target_shares(
date,
portfolio,
data,
&row.symbol,
target,
reason,
intraday_turnover,
execution_cursors,
global_execution_cursor,
commission_state,
report,
)
};
if fallback_references.contains_key(&row.symbol) {
self.with_etf_daily_open(execute)?;
for fill in &mut report.fill_events[first_fill..] {
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
} else { execute()?; }
}
}
Ok(())
}
pub(crate) fn pending_etf_target_count(&self) -> usize {
self.deferred_etf_targets.borrow().len()
}
/// Called at the opening clock, after settlement/corporate actions and
/// auction callbacks. It never sends a stock order or replays a strategy.
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
if self.has_open_orders() {
if self.pending_etf_target_count() > 0 {
report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into());
}
return Ok(report);
}
let due = self.deferred_etf_targets.borrow_mut().take_due(date);
let dates = data.calendar().iter().collect::<Vec<_>>();
for target in due {
let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?;
if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); }
if let Some(reason) = instrument.dated_market_absence_reason(date) {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol));
continue;
}
let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?;
if !snapshot.open.is_finite() || snapshot.open <= 0.0 {
return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol)));
}
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
let before_quantity = position.map_or(0, |p| p.quantity);
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence {
has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()),
last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
}, data.calendar()).map_err(BacktestError::Execution)?;
let denial = if target.side == pool::OrderSide::Buy {
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
} else { permission.sell_denial };
if let Some(denial) = denial {
report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol));
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。"));
continue;
}
if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions {
report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol));
continue;
}
let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?;
let current_value = snapshot.open * f64::from(before_quantity);
let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value)
|| (target.side == pool::OrderSide::Sell && value >= current_value);
let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value);
let mut sub = BrokerExecutionReport::default();
if !satisfied {
let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?;
let intent = match limit {
Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() },
None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() },
};
let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time()));
let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date }));
let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision {
order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default()
}));
self.runtime_intraday_start_time.set(old_time);
self.runtime_resting_order_origin.set(old_origin);
sub = outcome?;
}
// The actual open determines the full requested shares. A clipped
// or rejected execution must not be recorded as completed entry.
let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol);
let goal_quantity = order.map_or(before_quantity, |order| match order.side {
OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity),
OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity),
});
let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" };
let positions = pool_positions(portfolio, date)?;
let state = portfolio.stock_pool_execution_state(&target.pool_id)
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?
.record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation {
symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value,
current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status,
}]).map_err(BacktestError::Execution)?
.observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?;
portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?;
for fill in &mut sub.fill_events {
fill.decision_date.get_or_insert(target.signal_date);
fill.order_created_date.get_or_insert(target.signal_at.date());
fill.execution_date.get_or_insert(date);
fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time()));
}
for order in &mut sub.order_events {
order.decision_date.get_or_insert(target.signal_date);
order.order_created_date.get_or_insert(target.signal_at.date());
order.execution_date.get_or_insert(date);
}
report.diagnostics.push(reason);
etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity}{};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity)));
report.order_events.extend(sub.order_events);
report.fill_events.extend(sub.fill_events);
report.position_events.extend(sub.position_events);
report.account_events.extend(sub.account_events);
report.process_events.extend(sub.process_events);
report.diagnostics.extend(sub.diagnostics);
}
Ok(report)
}
}
@@ -0,0 +1,684 @@
fn pool_batch_data() -> DataSet {
pool_batch_data_with(|_| true)
}
fn pool_batch_data_with(change: impl Fn(&mut IntradayExecutionQuote) -> bool) -> DataSet {
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).into(),
..limit_test_instrument()
})
.collect();
let snapshots = symbols
.iter()
.map(|symbol| DailyMarketSnapshot {
symbol: (*symbol).into(),
..limit_test_snapshot()
})
.collect();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
symbol: (*symbol).into(),
..limit_test_candidate(true, true)
})
.collect();
let mut quotes = Vec::new();
for minute in [30, 31, 32, 33, 34, 36] {
for symbol in symbols {
let price = if symbol == "000001.SZ" && minute > 30 {
10.5
} else {
10.0
};
let mut quote = limit_test_quote(price, price, price);
quote.symbol = symbol.into();
quote.timestamp = quote.date.and_hms_opt(9, minute, 0).unwrap();
quote.volume_delta = 200;
quote.bid1_volume = 200;
quote.ask1_volume = 200;
quote.amount_delta = price * 200.0;
if change(&mut quote) {
quotes.push(quote);
}
}
}
DataSet::from_components_with_actions_and_quotes(
instruments,
snapshots,
Vec::new(),
candidates,
vec![limit_test_benchmark()],
Vec::new(),
quotes,
)
.unwrap()
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()])
}
fn pool_batch_decision(symbol: &str, generation: &str, end: &str) -> StrategyDecision {
use crate::stock_pool_execution as pool;
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let symbols = vec![symbol.to_owned()];
let rule = pool::StockPoolExecutionRule {
pricing_mode: pool::POOL_PRICE_FIXED_LIMIT.into(),
fixed_prices: [
("000001.SZ".into(), rust_decimal::Decimal::new(104, 1)),
("000002.SZ".into(), 10.into()),
("000003.SZ".into(), 10.into()),
]
.into(),
window_end: end.into(),
..Default::default()
};
StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(pool::FrozenStockPoolIntent {
pool_id: "batch-test".into(),
signal_date: signal,
frozen_equity: 2000.into(),
selection: pool::StockPoolSelection {
trade_date: signal,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols.clone(),
final_symbols: symbols,
exclusion_reasons: Default::default(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some(generation.into()),
},
members: vec![pool::StockPoolMemberSpec {
symbol: symbol.into(),
recommendation_reason: String::new(),
requested_order: 0,
target_weight_bps: None,
stop_loss: None,
take_profit: None,
}],
rule,
constraints: pool::StockPoolDecisionConstraints {
target_holding_count: Some(1),
..Default::default()
},
invest_ratio_bps: 10000,
reserve_cash: 0.into(),
out_of_pool_policy: "reduce_to_zero_when_sellable".into(),
generation: generation.into(),
}),
}],
..Default::default()
}
}
fn pool_batch_broker(partial: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let cost = ChinaAShareCostModel::from_trading_constraints(
crate::risk_control::TradingConstraintConfig {
commission_rate: 0.0,
minimum_commission: 0.0,
stamp_tax_rate_before_change: 0.0,
stamp_tax_rate_after_change: 0.0,
transfer_fee_rate: 0.0,
..Default::default()
},
);
let broker =
BrokerSimulator::new_with_execution_price(cost, ChinaEquityRuleHooks, PriceField::Open)
.with_matching_type(if partial {
MatchingType::MinuteLast
} else {
MatchingType::NextBarOpen
})
.with_volume_limit(partial)
.with_volume_percent(0.5)
.with_liquidity_limit(false)
.with_inactive_limit(false);
if partial {
broker
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
} else {
broker
}
}
fn pool_batch_account() -> PortfolioState {
let mut account = PortfolioState::new(0.0);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
200,
10.0,
);
account
}
fn pool_batch_tick(
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
account: &mut PortfolioState,
data: &DataSet,
minute: u32,
decision: &StrategyDecision,
) -> BrokerExecutionReport {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
broker
.runtime_intraday_start_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
broker
.runtime_intraday_end_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
broker.execute(date, account, data, decision).unwrap()
}
#[test]
fn stock_pool_pending_sell_continues_buy_after_actual_fill_without_strategy_rerun() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let initial = broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "first", "09:35"),
)
.unwrap();
assert!(initial.fill_events.is_empty());
assert_eq!(broker.open_order_views().len(), 1);
assert_eq!(broker.open_order_views()[0].side, OrderSide::Sell);
let done = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert!(
done.fill_events
.iter()
.any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell)
);
assert_eq!(
account.position("000002.SZ").map(|p| p.quantity),
Some(200),
"sell proceeds must trigger the retained buy phase: {:?}",
done.diagnostics
);
assert!(
account
.position("000001.SZ")
.is_none_or(|p| p.quantity == 0)
);
let repeated = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
assert!(repeated.order_events.is_empty() && repeated.fill_events.is_empty());
}
#[test]
fn stock_pool_partial_sell_waits_for_the_whole_batch_and_never_reissues_buys() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "partial", "09:35"),
)
.unwrap();
let first = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert!(account.position("000002.SZ").is_none());
assert!(
first
.order_events
.iter()
.all(|event| event.side == OrderSide::Sell)
);
let second = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
let third = pool_batch_tick(
&broker,
&mut account,
&data,
33,
&StrategyDecision::default(),
);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
let ids = second
.order_events
.iter()
.chain(&third.order_events)
.filter(|event| event.side == OrderSide::Buy)
.filter_map(|event| event.order_id)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
ids.len(),
1,
"one buy intention; partial reports must keep its ID"
);
assert!(
pool_batch_tick(
&broker,
&mut account,
&data,
34,
&StrategyDecision::default()
)
.order_events
.is_empty()
);
}
#[test]
fn stock_pool_delayed_sell_does_not_start_buys_after_the_configured_window() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "expired", "09:32"),
)
.unwrap();
pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
let last = pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
assert!(account.position("000002.SZ").is_none());
assert!(
last.order_events
.iter()
.all(|event| event.side == OrderSide::Sell)
);
assert!(
last.diagnostics
.iter()
.any(|event| event.contains("unsubmitted_phase_expired"))
);
assert!(!broker.has_pending_stock_pool_execution());
}
#[test]
fn stock_pool_new_signal_supersedes_the_unsubmitted_buy_phase() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(true);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "old", "09:35"),
)
.unwrap();
pool_batch_tick(
&broker,
&mut account,
&data,
31,
&pool_batch_decision("000003.SZ", "new", "09:35"),
);
pool_batch_tick(
&broker,
&mut account,
&data,
32,
&StrategyDecision::default(),
);
pool_batch_tick(
&broker,
&mut account,
&data,
33,
&StrategyDecision::default(),
);
assert!(account.position("000002.SZ").is_none());
assert_eq!(account.position("000003.SZ").unwrap().quantity, 200);
assert!(!broker.has_pending_stock_pool_execution());
}
#[test]
fn stock_pool_after_sell_uses_fresh_quotes_and_actual_submission_clock() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data_with(|quote| {
if quote.symbol == "000002.SZ" {
quote.last_price = 10.2;
quote.bid1 = 10.2;
quote.ask1 = 10.2;
quote.amount_delta = 2040.0;
}
true
});
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let mut decision = pool_batch_decision("000002.SZ", "fresh", "09:35");
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
contract.rule.pricing_mode = crate::stock_pool_execution::POOL_PRICE_FORMULA_LIMIT.into();
contract.rule.sell_offset_bps = 400;
}
broker
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
.unwrap();
let result = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert_eq!(
account.position("000002.SZ").unwrap().quantity,
100,
"2000/10.2 rounds to one 100-share lot, not 200 at stale open"
);
let fill = result
.fill_events
.iter()
.find(|fill| fill.symbol == "000002.SZ")
.unwrap();
assert_eq!(fill.price, 10.2);
assert_eq!(
fill.execution_start_timestamp,
Some(date.and_hms_opt(9, 31, 0).unwrap())
);
let event = result
.order_events
.iter()
.find(|event| event.side == OrderSide::Buy)
.unwrap();
assert_eq!(event.decision_date, Some(signal));
assert_eq!(event.order_created_date, Some(date));
}
#[test]
fn stock_pool_after_sell_rejects_missing_quote_instead_of_reusing_daily_open() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data_with(|quote| quote.symbol != "000002.SZ");
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "missing", "09:35"),
)
.unwrap();
broker
.runtime_intraday_start_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
broker
.runtime_intraday_end_time
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
let error = broker
.execute(date, &mut account, &data, &StrategyDecision::default())
.unwrap_err();
assert!(
error
.to_string()
.contains("stock_pool_execution_quote_missing:000002.SZ"),
"{error}"
);
assert!(account.position("000002.SZ").is_none());
}
#[test]
fn stock_pool_delayed_take_profit_does_not_rebuy_the_same_generation_exit() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = PortfolioState::new(0.0);
account.position_mut("000001.SZ").buy(
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
200,
9.0,
);
let mut decision = pool_batch_decision("000002.SZ", "take-profit", "09:35");
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
let symbols = vec!["000001.SZ".to_owned(), "000002.SZ".to_owned()];
contract.selection.requested_symbols = symbols.clone();
contract.selection.normal_trading_symbols = symbols.clone();
contract.selection.risk_eligible_symbols = symbols.clone();
contract.selection.final_symbols = symbols;
contract.constraints.target_holding_count = Some(2);
contract.members.insert(
0,
crate::stock_pool_execution::StockPoolMemberSpec {
symbol: "000001.SZ".into(),
recommendation_reason: String::new(),
requested_order: 0,
target_weight_bps: None,
stop_loss: None,
take_profit: Some(rust_decimal::Decimal::new(5, 2)),
},
);
contract.members[1].requested_order = 1;
}
broker
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
.unwrap();
let result = pool_batch_tick(
&broker,
&mut account,
&data,
31,
&StrategyDecision::default(),
);
assert!(
account
.position("000001.SZ")
.is_none_or(|p| p.quantity == 0)
);
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
assert!(
!result
.order_events
.iter()
.any(|event| event.symbol == "000001.SZ" && event.side == OrderSide::Buy)
);
}
#[test]
fn stock_pool_pending_phase_cannot_cross_the_execution_session() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let data = pool_batch_data();
let broker = pool_batch_broker(false);
let mut account = pool_batch_account();
let mut report = broker
.execute_with_event_dates(
date,
signal,
signal,
&mut account,
&data,
&pool_batch_decision("000002.SZ", "end", "09:35"),
)
.unwrap();
assert!(broker.has_pending_stock_pool_execution());
broker.finish_stock_pool_session(date, &mut report);
assert!(!broker.has_pending_stock_pool_execution());
assert!(
report
.diagnostics
.iter()
.any(|event| event.contains("unsubmitted_phase_expired"))
);
assert_eq!(
broker.open_order_views().len(),
1,
"session cleanup preserves broker order history and remainder"
);
}
#[test]
fn stock_pool_engine_drives_the_pending_buy_without_a_minute_strategy_callback() {
use crate::{BacktestConfig, BacktestEngine, BacktestError, Strategy, StrategyContext};
struct DailyPool;
impl Strategy for DailyPool {
fn name(&self) -> &str {
"daily-pool-batch"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
fn schedule_rules(&self) -> Vec<crate::ScheduleRule> {
vec![
crate::ScheduleRule::daily("open", crate::ScheduleStage::OnDay)
.with_time_rule(crate::ScheduleTimeRule::physical_time(9, 30)),
]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
_: &crate::ScheduleRule,
) -> Result<StrategyDecision, BacktestError> {
if ctx.execution_date.day() == 2 {
Ok(StrategyDecision {
order_intents: vec![OrderIntent::LimitTargetShares {
symbol: "000001.SZ".into(),
target_quantity: 200,
limit_price: 10.0,
reason: "initial-entry".into(),
}],
..Default::default()
})
} else {
Ok(pool_batch_decision("000002.SZ", "rotation", "09:35"))
}
}
fn on_minute(
&mut self,
_: &StrategyContext<'_>,
_: &IntradayExecutionQuote,
) -> Result<StrategyDecision, BacktestError> {
panic!("this daily strategy must not be rerun to continue a pending batch")
}
}
use chrono::Datelike;
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let last = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let base = pool_batch_data();
let mut market = Vec::new();
let mut factors = Vec::new();
let mut candidates = Vec::new();
let mut benchmarks = Vec::new();
let mut quotes = Vec::new();
for date in [first, last] {
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
let mut row = base.market(first, symbol).unwrap().clone();
row.date = date;
market.push(row);
let mut row = base.candidate(first, symbol).unwrap().clone();
row.date = date;
candidates.push(row);
factors.push(crate::data::DailyFactorSnapshot {
date,
symbol: symbol.into(),
market_cap_bn: 10.,
free_float_cap_bn: 10.,
pe_ttm: 10.,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.),
extra_factors: Default::default(),
});
for original in base.execution_quotes_on(first, symbol) {
let mut quote = original.clone();
quote.date = date;
quote.timestamp = date.and_time(original.timestamp.time());
quotes.push(quote);
}
}
let mut row = limit_test_benchmark();
row.date = date;
benchmarks.push(row);
}
let data = DataSet::from_components_with_actions_and_quotes(
base.instruments().values().cloned().collect(),
market,
factors,
candidates,
benchmarks,
Vec::new(),
quotes,
)
.unwrap()
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()]);
let config = BacktestConfig {
initial_cash: 2000.0,
benchmark_code: "000852.SH".into(),
start_date: Some(first),
end_date: Some(last),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
};
let result = BacktestEngine::new(data, DailyPool, pool_batch_broker(false), config)
.run()
.unwrap();
assert_eq!(
result.fills.len(),
3,
"initial buy, delayed sell, resumed buy: orders={:?} equity={:?}",
result.order_events,
result.equity_curve
);
assert_eq!(result.fills[2].symbol, "000002.SZ");
assert_eq!(result.fills[2].quantity, 200);
assert_eq!(
result.fills[2].execution_timestamp,
Some(last.and_hms_opt(9, 31, 0).unwrap())
);
assert_eq!(result.holdings_summary.len(), 1);
}
+52
View File
@@ -5,6 +5,7 @@ use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::fixed_point::{FixedChinaAShareCostModel, FixedMoney, FixedTradingCost};
use crate::risk_control::TradingConstraintConfig;
use crate::Instrument;
#[derive(Debug, Clone, Copy)]
pub struct TradingCost {
@@ -35,6 +36,17 @@ impl TradingCost {
pub trait CostModel {
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost;
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, _instrument: Option<&Instrument>) -> TradingCost {
self.calculate(date, side, gross_amount)
}
fn calculate_with_order_state_for_instrument(
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, _instrument: Option<&Instrument>,
) -> TradingCost {
self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state)
}
fn calculate_with_order_state(
&self,
date: NaiveDate,
@@ -215,6 +227,27 @@ impl ChinaAShareCostModel {
}
impl CostModel for ChinaAShareCostModel {
fn calculate_for_instrument(&self, date: NaiveDate, side: OrderSide, gross_amount: f64, instrument: Option<&Instrument>) -> TradingCost {
let mut cost = self.calculate(date, side, gross_amount);
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
cost.stamp_tax = 0.0;
cost.transfer_fee = 0.0;
}
cost
}
fn calculate_with_order_state_for_instrument(
&self, date: NaiveDate, side: OrderSide, gross_amount: f64,
order_id: Option<u64>, commission_state: &mut BTreeMap<u64,f64>, instrument: Option<&Instrument>,
) -> TradingCost {
let mut cost = self.calculate_with_order_state(date, side, gross_amount, order_id, commission_state);
if instrument.is_some_and(Instrument::is_exchange_traded_fund) {
cost.stamp_tax = 0.0;
cost.transfer_fee = 0.0;
}
cost
}
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
if gross_amount <= 0.0 {
return TradingCost {
@@ -273,6 +306,25 @@ impl CostModel for ChinaAShareCostModel {
mod tests {
use super::*;
#[test]
fn fund_fees_use_admitted_instrument_type_and_share_the_order_commission_budget() {
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
let model=ChinaAShareCostModel::from_trading_constraints(TradingConstraintConfig{commission_rate:0.0003,minimum_commission:5.,transfer_fee_rate:0.00001,..Default::default()});
let mut instrument=Instrument{symbol:"510300.SH".into(),name:"fixture".into(),board:"ETF".into(),round_lot:100,listed_at:Some(day),delisted_at:None,status:"active".into()};
for side in [OrderSide::Buy,OrderSide::Sell] {
let cost=model.calculate_for_instrument(day,side,10_000.,Some(&instrument));
assert_eq!(cost.commission,5.);assert_eq!(cost.stamp_tax,0.);assert_eq!(cost.transfer_fee,0.);
let mut state=BTreeMap::new();
let one=model.calculate_with_order_state_for_instrument(day,side,1_000.,Some(1),&mut state,Some(&instrument));
let two=model.calculate_with_order_state_for_instrument(day,side,9_000.,Some(1),&mut state,Some(&instrument));
assert_eq!(one.total()+two.total(),cost.total());
}
instrument.board="SH".into();
let stock=model.calculate_for_instrument(day,OrderSide::Sell,10_000.,Some(&instrument));
assert_eq!(stock.stamp_tax,5.);assert_eq!(stock.transfer_fee,0.1);
assert_eq!(stock.total(),model.calculate(day,OrderSide::Sell,10_000.).total());
}
#[test]
fn default_quantizes_fees_to_micro_yuan() {
let model = ChinaAShareCostModel::default();
+297 -130
View File
@@ -3,8 +3,9 @@ use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet};
use std::sync::{Arc, OnceLock};
use ahash::AHashMap;
use ahash::{AHashMap, AHashSet};
use chrono::{NaiveDate, NaiveDateTime};
use compact_str::CompactString;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -122,7 +123,7 @@ pub enum PriceField {
pub struct DailyMarketSnapshot {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub symbol: String,
pub symbol: CompactString,
pub timestamp: Option<String>,
pub day_open: f64,
pub open: f64,
@@ -199,13 +200,13 @@ impl DailyMarketSnapshot {
}
}
pub type NumericFactorMap = BTreeMap<Cow<'static, str>, f64>;
pub use crate::numeric_factors::NumericFactorMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyFactorSnapshot {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub symbol: String,
pub symbol: CompactString,
pub market_cap_bn: f64,
pub free_float_cap_bn: f64,
pub pe_ttm: f64,
@@ -232,7 +233,7 @@ pub struct BenchmarkSnapshot {
pub struct CandidateEligibility {
#[serde(with = "date_format")]
pub date: NaiveDate,
pub symbol: String,
pub symbol: CompactString,
pub is_st: bool,
#[serde(default)]
pub is_star_st: bool,
@@ -283,6 +284,8 @@ pub struct CorporateAction {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntradayExecutionQuote {
#[serde(default)]
pub observation_kind: QuoteObservationKind,
#[serde(with = "date_format")]
pub date: NaiveDate,
pub symbol: String,
@@ -300,6 +303,14 @@ pub struct IntradayExecutionQuote {
pub trading_phase: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QuoteObservationKind {
#[default]
Unspecified,
MinuteBar,
}
/// Sparse same-day fields layered onto an already-built immutable daily panel.
///
/// These fields do not participate in daily price series, adjustment series,
@@ -1204,7 +1215,7 @@ impl SymbolPriceSeries {
fn snapshot_at(&self, index: usize) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: self.dates[index],
symbol: self.symbol.clone(),
symbol: self.symbol.as_str().into(),
timestamp: self.timestamps[index].clone(),
day_open: self.day_opens[index],
open: self.opens[index],
@@ -1406,6 +1417,7 @@ pub struct DataSet {
corporate_actions_by_date: Arc<BTreeMap<NaiveDate, Vec<CorporateAction>>>,
execution_quotes_by_date: Arc<HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>>,
execution_quote_dates: Arc<Vec<NaiveDate>>,
condition_totals: Arc<std::sync::Mutex<crate::stock_pool_quote_facts::SessionTotalsCache>>,
order_book_depth_index: Arc<HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>>,
benchmark_by_date: Arc<BTreeMap<NaiveDate, BenchmarkSnapshot>>,
market_series_by_symbol: Arc<AHashMap<String, Arc<SymbolPriceSeries>>>,
@@ -1574,48 +1586,15 @@ impl DataSet {
benchmark_by_date: BTreeMap::new(),
corporate_actions_by_date: BTreeMap::new(),
};
for mut bundle in bundles {
// Indexed collection retains chronological error precedence while each
// worker validates and normalizes only its owned day buffers.
let prepared = bundles
.into_par_iter()
.map(normalize_daily_snapshot_bundle)
.collect::<Vec<_>>();
for bundle in prepared {
let bundle = bundle?;
let date = bundle.date;
if bundle.benchmark.date != date {
return Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "benchmark",
bundle_date: date,
row_date: bundle.benchmark.date,
symbol: bundle.benchmark.benchmark.clone(),
});
}
validate_daily_bundle_component_dates(
&bundle.market,
date,
"market",
|row| row.date,
|row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.factors,
date,
"factor",
|row| row.date,
|row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.candidates,
date,
"candidate",
|row| row.date,
|row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.corporate_actions,
date,
"corporate_action",
|row| row.date,
|row| row.symbol.as_str(),
)?;
sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str());
bundle.factors = normalize_factor_snapshots(bundle.factors)?;
sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str());
sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str());
if !bundle.market.is_empty() {
grouped.market_by_date.insert(date, bundle.market);
}
@@ -1940,6 +1919,7 @@ impl DataSet {
candidate_row_positions_by_date: Arc::new(candidate_row_positions_by_date),
corporate_actions_by_date: Arc::new(corporate_actions_by_date),
execution_quotes_by_date: Arc::new(execution_quotes_by_date),
condition_totals: Arc::new(std::sync::Mutex::new(Default::default())),
execution_quote_dates: Arc::new(execution_quote_dates),
order_book_depth_index: Arc::new(order_book_depth_index),
benchmark_by_date: Arc::new(benchmark_by_date),
@@ -2270,6 +2250,17 @@ impl DataSet {
.unwrap_or(&[])
}
/// Counts market, factor, candidate, benchmark and corporate-action rows without cloning them.
pub fn snapshot_row_counts(&self) -> (usize, usize, usize, usize, usize) {
(
self.market_by_date.values().map(Vec::len).sum(),
self.factor_by_date.values().map(Vec::len).sum(),
self.candidate_by_date.values().map(Vec::len).sum(),
self.benchmark_by_date.len(),
self.corporate_actions_by_date.values().map(Vec::len).sum(),
)
}
pub fn execution_quotes_on(&self, date: NaiveDate, symbol: &str) -> &[IntradayExecutionQuote] {
self.execution_quotes_by_date
.get(&date)
@@ -2278,6 +2269,15 @@ impl DataSet {
.unwrap_or(&[])
}
pub fn execution_session_totals(&self, symbol: &str, at: NaiveDateTime) -> Result<(rust_decimal::Decimal, rust_decimal::Decimal), String> {
let mut cache = self.condition_totals.lock().map_err(|_| "stock_pool_session_prefix_cache_poisoned")?;
if cache.date != Some(at.date()) {
cache.date = Some(at.date());
cache.symbols.clear();
}
cache.symbols.entry(symbol.into()).or_insert_with(|| crate::stock_pool_quote_facts::MinutePrefix::build(at.date(), symbol, self.execution_quotes_on(at.date(), symbol))).at(at)
}
pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool {
self.execution_quotes_by_date
.get(&date)
@@ -2450,6 +2450,7 @@ impl DataSet {
/// Replaces the run-local execution quote layer without touching the
/// immutable daily panel.
pub fn replace_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let execution_quotes_by_date = build_execution_quote_index(quotes);
let quote_count = execution_quotes_by_date
.values()
@@ -2465,6 +2466,7 @@ impl DataSet {
}
pub fn add_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new();
for quote in quotes {
grouped
@@ -2565,6 +2567,7 @@ impl DataSet {
}
pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let removed = Arc::make_mut(&mut self.execution_quotes_by_date).remove(&date);
let Some(rows_by_symbol) = removed else {
return 0;
@@ -2577,6 +2580,7 @@ impl DataSet {
}
pub fn release_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
let row_count = self
.execution_quotes_by_date
.get(&date)
@@ -2865,7 +2869,7 @@ impl DataSet {
.filter_map(|snapshot| {
factor_numeric_value(snapshot, &field).map(|value| FactorValue {
date: snapshot.date,
symbol: snapshot.symbol.clone(),
symbol: snapshot.symbol.to_string(),
field: field.clone(),
value,
})
@@ -2959,7 +2963,7 @@ impl DataSet {
.or_else(|| row.extra_factors.get("margin_all"))
.is_some_and(|value| *value > 0.0)
})
.map(|row| row.symbol.clone())
.map(|row| row.symbol.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
@@ -3051,7 +3055,7 @@ impl DataSet {
if snapshot.prev_close.is_finite() && snapshot.prev_close > 0.0 {
Some(FactorValue {
date: snapshot.date,
symbol: snapshot.symbol.clone(),
symbol: snapshot.symbol.to_string(),
field: "price_change_rate".to_string(),
value: snapshot.close / snapshot.prev_close - 1.0,
})
@@ -3173,7 +3177,7 @@ impl DataSet {
if let Some(value) = factor_numeric_value(snapshot, field) {
return Some(FactorValue {
date: *factor_date,
symbol: snapshot.symbol.clone(),
symbol: snapshot.symbol.to_string(),
field: field.clone(),
value,
});
@@ -3375,6 +3379,12 @@ impl DataSet {
.unwrap_or(&[])
}
pub fn is_reference_only_benchmark(&self, symbol: &str) -> bool {
if symbol != self.benchmark_code() { return false; }
let Some(symbol_id) = self.symbol_id(symbol) else { return true; };
!self.candidate_symbol_ids_by_date.values().any(|ids| ids.contains(&symbol_id))
}
pub fn bundle_on(&self, date: NaiveDate) -> Result<DailySnapshotBundle, DataSetError> {
let benchmark = self
.benchmark(date)
@@ -3518,7 +3528,7 @@ impl DataSet {
if let Some(value) = factor_numeric_value(snapshot, field) {
rows.push(FactorValue {
date: snapshot.date,
symbol: snapshot.symbol.clone(),
symbol: snapshot.symbol.to_string(),
field: output_field.clone(),
value,
});
@@ -4316,7 +4326,7 @@ fn daily_market_price_bar(snapshot: &DailyMarketSnapshot) -> PriceBar {
PriceBar {
date: snapshot.date,
timestamp: snapshot.timestamp.clone(),
symbol: snapshot.symbol.clone(),
symbol: snapshot.symbol.to_string(),
frequency: "1d".to_string(),
open: snapshot.open,
high: snapshot.high,
@@ -4377,7 +4387,7 @@ fn normalize_factor_snapshots(
{
return Err(DataSetError::ReservedTypedFactorInExtraMap {
date: snapshot.date,
symbol: snapshot.symbol,
symbol: snapshot.symbol.to_string(),
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
});
}
@@ -4386,14 +4396,14 @@ fn normalize_factor_snapshots(
{
return Err(DataSetError::InvalidBackwardAdjustmentFactor {
date: snapshot.date,
symbol: snapshot.symbol,
symbol: snapshot.symbol.to_string(),
value,
});
}
let already_normalized = snapshot.extra_factors.iter().all(|(field, value)| {
let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\'');
let trimmed = field.as_str().trim().trim_matches('"').trim_matches('\'');
!trimmed.is_empty()
&& trimmed == field.as_ref()
&& trimmed == field.as_str()
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
&& value.is_finite()
});
@@ -4404,15 +4414,15 @@ fn normalize_factor_snapshots(
.extra_factors
.into_iter()
.filter_map(|(field, value)| {
let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\'');
let trimmed = field.as_str().trim().trim_matches('"').trim_matches('\'');
if trimmed.is_empty() || !value.is_finite() {
None
} else if trimmed == field.as_ref()
} else if trimmed == field.as_str()
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
{
Some((field, value))
} else {
Some((Cow::Owned(trimmed.to_ascii_lowercase()), value))
Some((CompactString::from(trimmed.to_ascii_lowercase()), value))
}
})
.collect();
@@ -4422,7 +4432,7 @@ fn normalize_factor_snapshots(
{
return Err(DataSetError::ReservedTypedFactorInExtraMap {
date: snapshot.date,
symbol: snapshot.symbol,
symbol: snapshot.symbol.to_string(),
field: BACKWARD_ADJUSTMENT_FACTOR_FIELD,
});
}
@@ -4440,6 +4450,38 @@ fn normalize_history_frequency(frequency: &str) -> Option<String> {
}
}
fn normalize_daily_snapshot_bundle(
mut bundle: DailySnapshotBundle,
) -> Result<DailySnapshotBundle, DataSetError> {
let date = bundle.date;
if bundle.benchmark.date != date {
return Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "benchmark",
bundle_date: date,
row_date: bundle.benchmark.date,
symbol: bundle.benchmark.benchmark.clone(),
});
}
validate_daily_bundle_component_dates(
&bundle.market, date, "market", |row| row.date, |row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.factors, date, "factor", |row| row.date, |row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.candidates, date, "candidate", |row| row.date, |row| row.symbol.as_str(),
)?;
validate_daily_bundle_component_dates(
&bundle.corporate_actions, date, "corporate_action", |row| row.date,
|row| row.symbol.as_str(),
)?;
sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str());
bundle.factors = normalize_factor_snapshots(bundle.factors)?;
sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str());
sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str());
Ok(bundle)
}
fn validate_daily_bundle_component_dates<T, D, S>(
rows: &[T],
bundle_date: NaiveDate,
@@ -4500,25 +4542,25 @@ fn build_symbol_id_index(
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
candidate_by_date: &BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
) -> AHashMap<String, u32> {
let mut symbols = instruments.keys().cloned().collect::<HashSet<_>>();
let mut symbols = instruments.keys().cloned().collect::<AHashSet<_>>();
for rows in market_by_date.values() {
for row in rows {
if !symbols.contains(row.symbol.as_str()) {
symbols.insert(row.symbol.clone());
symbols.insert(row.symbol.to_string());
}
}
}
for rows in factor_by_date.values() {
for row in rows {
if !symbols.contains(row.symbol.as_str()) {
symbols.insert(row.symbol.clone());
symbols.insert(row.symbol.to_string());
}
}
}
for rows in candidate_by_date.values() {
for row in rows {
if !symbols.contains(row.symbol.as_str()) {
symbols.insert(row.symbol.clone());
symbols.insert(row.symbol.to_string());
}
}
}
@@ -4542,10 +4584,11 @@ fn build_group_symbol_ids<T, F>(
symbol_of: F,
) -> BTreeMap<NaiveDate, Vec<u32>>
where
F: Fn(&T) -> &str + Copy,
T: Sync,
F: Fn(&T) -> &str + Copy + Send + Sync,
{
groups
.iter()
.par_iter()
.map(|(date, rows)| {
let symbol_ids = rows
.iter()
@@ -4558,6 +4601,8 @@ where
debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1]));
(*date, symbol_ids)
})
.collect::<Vec<_>>()
.into_iter()
.collect()
}
@@ -4637,7 +4682,7 @@ fn build_factor_market_cap_order(
.collect()
}
fn build_dense_row_positions<T>(
fn build_dense_row_positions<T: Sync>(
groups: &BTreeMap<NaiveDate, Vec<T>>,
symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>,
symbol_count: usize,
@@ -4648,8 +4693,11 @@ fn build_dense_row_positions<T>(
return None;
}
let mut positions_by_date = BTreeMap::new();
for (date, rows) in groups {
// Each task owns one bounded day index. No partial index is published if
// any day has a missing, duplicate, or misaligned symbol identifier.
groups
.par_iter()
.map(|(date, rows)| {
let symbol_ids = symbol_ids_by_date.get(date)?;
if rows.len() != symbol_ids.len() {
return None;
@@ -4662,9 +4710,10 @@ fn build_dense_row_positions<T>(
}
*position = u32::try_from(row_index).ok()?;
}
positions_by_date.insert(*date, positions);
}
Some(positions_by_date)
Some((*date, positions))
})
.collect::<Option<Vec<_>>>()
.map(|days| days.into_iter().collect())
}
fn build_calendar_series_end_positions(
@@ -4936,7 +4985,7 @@ fn build_fundamental_universe_for_date(
continue;
}
rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(),
symbol: factor.symbol.to_string(),
market_cap_bn,
free_float_cap_bn: decision_free_float_cap_bn(factor),
});
@@ -5006,7 +5055,7 @@ fn build_eligible_universe_for_date_from_factors(
date,
candidate,
market,
instruments.get(&factor.symbol),
instruments.get(factor.symbol.as_str()),
risk_config,
)
.is_some()
@@ -5019,7 +5068,7 @@ fn build_eligible_universe_for_date_from_factors(
}
let free_float_cap_bn = decision_free_float_cap_bn(factor);
rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(),
symbol: factor.symbol.to_string(),
market_cap_bn,
free_float_cap_bn,
});
@@ -5036,7 +5085,7 @@ fn build_eligible_universe_for_date_from_factors(
pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -5064,7 +5113,7 @@ mod tests {
fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: None,
day_open: prev_close,
open: prev_close,
@@ -5151,7 +5200,7 @@ mod tests {
&run_data.execution_quote_dates
));
run_data.add_execution_quotes(vec![IntradayExecutionQuote {
run_data.add_execution_quotes(vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
@@ -5294,7 +5343,7 @@ mod tests {
vec![benchmark_row("2025-01-02", 12.0)],
)
.unwrap();
let quote = IntradayExecutionQuote {
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000001.SZ".to_string(),
timestamp,
@@ -5346,10 +5395,10 @@ mod tests {
for (symbol_index, symbol) in symbols.into_iter().enumerate().rev() {
let close = 10.0 + date_index as f64 + symbol_index as f64;
let mut market_row = market_row(&date_text, close, 1_000_000);
market_row.symbol = symbol.to_string();
market_row.symbol = symbol.into();
let factor_row = DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn: 100.0 + close,
free_float_cap_bn: 80.0 + close,
pe_ttm: 0.0,
@@ -5360,7 +5409,7 @@ mod tests {
};
let candidate_row = CandidateEligibility {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -5396,7 +5445,7 @@ mod tests {
successor_cash: None,
};
corporate_actions.push(corporate_action.clone());
execution_quotes.push(IntradayExecutionQuote {
execution_quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: symbols[0].to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -5436,6 +5485,10 @@ mod tests {
)
.expect("daily bundle dataset");
let row_count = dates.len() * symbols.len();
let expected_counts = (row_count, row_count, row_count, dates.len(), dates.len());
assert_eq!(flat.snapshot_row_counts(), expected_counts);
assert_eq!(grouped.snapshot_row_counts(), expected_counts);
assert_eq!(flat.calendar().days(), grouped.calendar().days());
assert_eq!(flat.benchmark_code(), grouped.benchmark_code());
for date in dates {
@@ -5510,6 +5563,122 @@ mod tests {
));
}
#[test]
fn parallel_daily_bundle_validation_keeps_earliest_error_and_component_order() {
let bundles = || (2..30).rev().map(|day| {
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
let mut benchmark = benchmark_row("2025-01-01", 20.0);
benchmark.date = date;
DailySnapshotBundle {
date, benchmark,
market: vec![market_row("2025-01-01", 10.0, 100)],
factors: Vec::new(), candidates: Vec::new(), corporate_actions: Vec::new(),
}
}).collect::<Vec<_>>();
for threads in [1, 2, 8] {
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
for _ in 0..4 {
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
Vec::new(), bundles(), Vec::new(),
));
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "market", bundle_date, ..
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
}
let mut values = bundles();
values.last_mut().unwrap().benchmark.date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
Vec::new(), values, Vec::new(),
));
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
kind: "benchmark", bundle_date, ..
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
}
}
#[test]
fn parallel_daily_symbol_indices_match_scalar_for_sparse_and_empty_days() {
let symbols = ["000001.SZ", "159915.SZ", "600000.SH", "932000.CSI", "custom-long-instrument"];
let index = symbols.iter().enumerate()
.map(|(id, symbol)| (symbol.to_string(), id as u32))
.collect::<AHashMap<_, _>>();
let groups = (1..29).map(|day| {
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
let rows = symbols.iter().enumerate()
.filter(|(id, _)| day % 7 != 0 && (*id + day as usize) % 3 != 0)
.map(|(_, symbol)| symbol.to_string()).collect::<Vec<_>>();
(date, rows)
}).collect::<BTreeMap<_, _>>();
let expected_ids = groups.iter().map(|(date, rows)| {
(*date, rows.iter().map(|symbol| index[symbol]).collect::<Vec<_>>())
}).collect::<BTreeMap<_, _>>();
let expected_positions = expected_ids.iter().map(|(date, ids)| {
let mut positions = vec![super::MISSING_ROW_POSITION; symbols.len()];
for (row, id) in ids.iter().enumerate() { positions[*id as usize] = row as u32; }
(*date, positions)
}).collect::<BTreeMap<_, _>>();
for threads in [1, 2, 8] {
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
let ids = super::build_group_symbol_ids(&groups, &index, String::as_str);
assert_eq!(ids, expected_ids);
assert_eq!(super::build_dense_row_positions(&groups, &ids, symbols.len()), Some(expected_positions.clone()));
});
}
}
#[test]
fn parallel_dense_index_rejects_invalid_days_without_publishing_partial_index() {
let day1 = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let day2 = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let groups = BTreeMap::from([(day1, vec![0, 1]), (day2, vec![0, 1])]);
let valid = BTreeMap::from([(day1, vec![0, 2]), (day2, vec![1, 2])]);
for threads in [1, 2, 8] {
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
for invalid in [vec![], vec![1], vec![1, 1], vec![1, 3], vec![1, u32::MAX]] {
let mut ids = valid.clone();
ids.insert(day2, invalid);
assert!(super::build_dense_row_positions(&groups, &ids, 3).is_none());
}
let mut missing = valid.clone();
missing.remove(&day2);
assert!(super::build_dense_row_positions(&groups, &missing, 3).is_none());
assert!(super::build_dense_row_positions(&groups, &valid, usize::MAX).is_none());
assert!(super::build_dense_row_positions(&groups, &valid, super::MAX_DENSE_ROW_INDEX_BYTES).is_none());
});
}
}
#[test]
fn symbol_id_union_preserves_lexical_order_and_all_component_sources() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let instrument = Instrument {
symbol: "932000.CSI".into(), name: "index".into(), board: "CSI".into(),
round_lot: 100, listed_at: None, delisted_at: None, status: "active".into(),
};
let mut market = market_row("2025-01-02", -0.0, 0);
market.symbol = "custom-long-instrument".into();
let factor = DailyFactorSnapshot {
date, symbol: "159915.SZ".into(), market_cap_bn: 0.0, free_float_cap_bn: 0.0,
pe_ttm: 0.0, turnover_ratio: None, effective_turnover_ratio: None,
adjustment_factor_backward1: None, extra_factors: NumericFactorMap::new(),
};
let candidate = CandidateEligibility {
date, symbol: "000001.SZ".into(), is_st: true, is_star_st: true,
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
is_kcb: false, is_one_yuan: false, risk_level_code: Some("test".into()),
};
let ids = super::build_symbol_id_index(
&HashMap::from([(instrument.symbol.clone(), instrument)]),
&BTreeMap::from([(date, vec![market.clone(), market])]),
&BTreeMap::from([(date, vec![factor])]),
&BTreeMap::from([(date, vec![candidate])]),
);
assert_eq!(ids, AHashMap::from_iter([
("000001.SZ".to_string(), 0), ("159915.SZ".to_string(), 1),
("932000.CSI".to_string(), 2), ("custom-long-instrument".to_string(), 3),
]));
}
#[test]
fn direct_symbol_id_snapshot_lookups_preserve_alignment_for_sparse_rows() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
@@ -5528,12 +5697,12 @@ mod tests {
};
let market = |symbol: &str, close: f64| {
let mut row = market_row("2025-01-02", close, 1_000_000);
row.symbol = symbol.to_string();
row.symbol = symbol.into();
row
};
let factor = |symbol: &str, market_cap_bn: f64| DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn,
free_float_cap_bn: market_cap_bn,
pe_ttm: 0.0,
@@ -5544,7 +5713,7 @@ mod tests {
};
let candidate = |symbol: &str| CandidateEligibility {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -5767,7 +5936,7 @@ mod tests {
.iter()
.map(|date| DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
@@ -5971,7 +6140,7 @@ mod tests {
.enumerate()
.map(|(index, symbol)| {
let mut row = market_row("2025-01-02", 10.0 + index as f64 / 1000.0, 1_000_000);
row.symbol = symbol.clone();
row.symbol = symbol.clone().into();
row
})
.collect::<Vec<_>>();
@@ -5980,7 +6149,7 @@ mod tests {
.enumerate()
.map(|(index, symbol)| DailyFactorSnapshot {
date,
symbol: symbol.clone(),
symbol: symbol.clone().into(),
market_cap_bn: 10.0 + index as f64 / 1000.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
@@ -5994,7 +6163,7 @@ mod tests {
.iter()
.map(|symbol| CandidateEligibility {
date,
symbol: symbol.clone(),
symbol: symbol.clone().into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -6107,7 +6276,7 @@ mod tests {
vec![benchmark_row("2025-01-02", 12.0)],
)
.unwrap();
let quote = |symbol: &str, time: &str| IntradayExecutionQuote {
let quote = |symbol: &str, time: &str| IntradayExecutionQuote { observation_kind: Default::default(),
date,
timestamp: NaiveDateTime::parse_from_str(
&format!("2025-01-02 {time}"),
@@ -6186,7 +6355,7 @@ mod tests {
#[test]
fn shared_execution_quote_release_does_not_clone_the_base_map() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let quote = IntradayExecutionQuote {
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
date,
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
@@ -6229,7 +6398,7 @@ mod tests {
}
#[test]
fn baseline_selection_uses_structured_instrument_dates_and_status_only() {
fn baseline_selection_uses_dated_lifecycle_not_latest_undated_status() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
symbol: "000001.SZ".to_string(),
@@ -6257,7 +6426,7 @@ mod tests {
Some(&instrument("退市测试", "active", None)),
date
));
assert!(!instrument_passes_baseline_selection(
assert!(instrument_passes_baseline_selection(
Some(&instrument("正常名称", "delisted", None)),
date
));
@@ -6283,14 +6452,14 @@ mod tests {
fn factor_numeric_value_normalizes_fields_without_changing_aliases() {
let snapshot = DailyFactorSnapshot {
date: NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 12.5,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.25),
extra_factors: BTreeMap::from([("custom_factor".into(), 3.5)]),
extra_factors: From::from([("custom_factor".into(), 3.5)]),
};
assert_eq!(factor_numeric_value(&snapshot, " MARKET_CAP "), Some(12.5));
@@ -6306,31 +6475,29 @@ mod tests {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let clean = normalize_factor_snapshots(vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 1.0,
free_float_cap_bn: 1.0,
pe_ttm: 1.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: BTreeMap::from([(Cow::Borrowed("amount"), 10.0)]),
extra_factors: From::from([(Cow::Borrowed("amount"), 10.0)]),
}])
.expect("normalize clean factor snapshot");
assert!(matches!(
clean[0].extra_factors.keys().next(),
Some(Cow::Borrowed("amount"))
));
assert_eq!(clean[0].extra_factors.keys().next().map(CompactString::as_str), Some("amount"));
assert!(!clean[0].extra_factors.keys().next().unwrap().is_heap_allocated());
let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 1.0,
free_float_cap_bn: 1.0,
pe_ttm: 1.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors: BTreeMap::from([
extra_factors: From::from([
(Cow::Owned(" CUSTOM_FACTOR ".to_string()), 2.0),
(Cow::Borrowed("bad_nan"), f64::NAN),
]),
@@ -6345,7 +6512,7 @@ mod tests {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let snapshot = |adjustment_factor_backward1, extra_factors| DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 1.0,
free_float_cap_bn: 1.0,
pe_ttm: 1.0,
@@ -6358,12 +6525,12 @@ mod tests {
assert!(matches!(
normalize_factor_snapshots(vec![snapshot(
Some(1.0),
BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]),
BTreeMap::from([(Cow::Borrowed(BACKWARD_ADJUSTMENT_FACTOR_FIELD), 1.0)]).into(),
)]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
));
assert!(matches!(
normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new())]),
normalize_factor_snapshots(vec![snapshot(Some(0.0), BTreeMap::new().into())]),
Err(DataSetError::InvalidBackwardAdjustmentFactor { .. })
));
for field in [
@@ -6376,7 +6543,7 @@ mod tests {
matches!(
normalize_factor_snapshots(vec![snapshot(
typed_value,
BTreeMap::from([(Cow::Borrowed(field), 2.0)]),
BTreeMap::from([(Cow::Borrowed(field), 2.0)]).into(),
)]),
Err(DataSetError::ReservedTypedFactorInExtraMap { .. })
),
@@ -6472,14 +6639,14 @@ mod tests {
}
DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: None,
extra_factors,
extra_factors: extra_factors.into(),
}
})
.collect(),
@@ -6531,14 +6698,14 @@ mod tests {
.iter()
.map(|date| DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.0),
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
})
.collect(),
Vec::new(),
@@ -6825,14 +6992,14 @@ mod tests {
.zip(factors)
.map(|(date, factor)| DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(factor),
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
})
.collect(),
Vec::new(),
@@ -6923,14 +7090,14 @@ mod tests {
.iter()
.map(|date| DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: (*date != dates[3]).then_some(1.0),
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
})
.collect(),
Vec::new(),
@@ -7002,7 +7169,7 @@ mod tests {
};
let market = |symbol: &str, prev_close: f64, close: f64| DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: prev_close,
open: prev_close,
@@ -7026,18 +7193,18 @@ mod tests {
let factor =
|symbol: &str, market_cap_bn: f64, free_float_cap_bn: f64| DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn,
free_float_cap_bn,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
};
let candidate = |symbol: &str| CandidateEligibility {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -7102,7 +7269,7 @@ mod tests {
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -7125,14 +7292,14 @@ mod tests {
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn: 10.0,
free_float_cap_bn: 9.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}],
Vec::new(),
vec![BenchmarkSnapshot {
@@ -7187,7 +7354,7 @@ mod tests {
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -7210,18 +7377,18 @@ mod tests {
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn: 10.0,
free_float_cap_bn: 9.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -7262,14 +7429,14 @@ mod tests {
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
let factor = DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 12.0,
free_float_cap_bn: 4.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
};
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
//! Historical ETF execution fallback. Never manufactures an intraday bar.
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use rust_decimal::Decimal;
use crate::{BacktestError, DataSet};
pub(crate) fn opening_time() -> NaiveTime {
NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time")
}
#[derive(Debug, Clone)]
pub(crate) struct EtfFallbackReference {
pub price: f64,
pub reference_date: NaiveDate,
/// None means the next official session is outside the loaded calendar.
/// No natural-day guess or price from beyond the requested run is used.
pub execute_on: Option<NaiveDate>,
pub immediate: bool,
}
pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result<EtfFallbackReference, BacktestError> {
let fail = |reason: &str| BacktestError::Execution(format!(
"etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}"
));
let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?;
if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() {
return Err(fail("verified_etf_identity_required"));
}
if instrument.dated_market_absence_reason(at.date()).is_some() {
return Err(fail("outside_instrument_lifecycle"));
}
if at.time() == opening_time() {
let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?;
if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); }
return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true });
}
let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?;
let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?;
if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); }
Ok(EtfFallbackReference {
price: close, reference_date: previous, immediate: false,
execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) },
})
}
#[derive(Debug, Clone)]
pub(crate) struct DeferredEtfTarget {
pub pool_id: String,
pub generation: String,
pub symbol: String,
pub signal_date: NaiveDate,
pub signal_at: NaiveDateTime,
pub execute_on: Option<NaiveDate>,
pub target_value: Decimal,
pub target_weight_bps: i32,
pub side: crate::stock_pool_execution::OrderSide,
pub max_positions: usize,
pub rule: std::sync::Arc<crate::stock_pool_execution::StockPoolExecutionRule>,
pub members: std::sync::Arc<Vec<crate::stock_pool_execution::StockPoolMemberSpec>>,
pub reason: String,
}
/// Owned by one broker/run. Replacing a full pool generation supersedes older
/// queued targets; order of the latest candidate list is retained.
#[derive(Debug, Default)]
pub(crate) struct DeferredEtfTargets {
generations: std::collections::BTreeMap<String, String>,
rows: Vec<DeferredEtfTarget>,
}
impl DeferredEtfTargets {
pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize {
if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; }
self.generations.insert(pool_id.into(), generation.into());
let before = self.rows.len();
self.rows.retain(|row| row.pool_id != pool_id);
before - self.rows.len()
}
pub fn upsert(&mut self, row: DeferredEtfTarget) {
if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) {
*existing = row;
} else { self.rows.push(row); }
}
pub fn take_due(&mut self, date: NaiveDate) -> Vec<DeferredEtfTarget> {
let mut due = Vec::new();
self.rows.retain(|row| {
if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true }
});
due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 });
due
}
pub fn len(&self) -> usize { self.rows.len() }
}
#[cfg(test)]
mod tests {
use super::*;
fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget {
let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap();
DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()}
}
#[test]
fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() {
use crate::stock_pool_execution::OrderSide::{Buy,Sell};
let mut queue=DeferredEtfTargets::default();
queue.replace_generation("pool","v1");
queue.upsert(target("510300.SH",Buy,"v1"));
queue.upsert(target("159915.SZ",Buy,"v1"));
assert_eq!(queue.replace_generation("pool","v1"),0);
assert_eq!(queue.replace_generation("pool","v2"),2);
queue.upsert(target("560450.SH",Buy,"v2"));
queue.upsert(target("159915.SZ",Sell,"v2"));
queue.upsert(target("510300.SH",Buy,"v2"));
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty());
let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap());
assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::<Vec<_>>(),vec!["159915.SZ","560450.SH","510300.SH"]);
assert!(due.iter().all(|v|v.generation=="v2"));
assert_eq!(queue.len(),0);
}
#[test]
fn no_loaded_next_session_is_not_guessed_from_natural_days() {
let mut queue=DeferredEtfTargets::default();
let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1");
item.execute_on=None;
queue.upsert(item);
assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty());
assert_eq!(queue.len(),1);
}
}
+6
View File
@@ -317,6 +317,8 @@ pub enum ProcessEventKind {
AccountDepositWithdraw,
AccountFinanceRepay,
AccountManagementFee,
SessionCapacityAudit,
EtfExecutionFallback,
}
impl ProcessEventKind {
@@ -362,6 +364,8 @@ impl ProcessEventKind {
Self::AccountDepositWithdraw => "account_deposit_withdraw",
Self::AccountFinanceRepay => "account_finance_repay",
Self::AccountManagementFee => "account_management_fee",
Self::SessionCapacityAudit => "session_capacity_audit",
Self::EtfExecutionFallback => "etf_execution_fallback",
}
}
@@ -393,6 +397,8 @@ impl ProcessEventKind {
| Self::AccountDepositWithdraw
| Self::AccountFinanceRepay
| Self::AccountManagementFee
| Self::SessionCapacityAudit
| Self::EtfExecutionFallback
| Self::Settlement
)
}
+205
View File
@@ -0,0 +1,205 @@
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeCapacityMode {
#[default]
ExecutionObservation,
CompletedBar,
SessionCapacityAudit,
}
impl VolumeCapacityMode {
pub fn validate(self, enabled: bool, has_execution_observations: bool) -> Result<(), CapacityError> {
if !enabled { return Ok(()); }
match self {
Self::ExecutionObservation if !has_execution_observations => Err(CapacityError::MissingObservation),
Self::CompletedBar => Err(CapacityError::MissingCompletedBar),
_ => Ok(()),
}
}
pub fn limits_execution_quantity(self) -> bool { self != Self::SessionCapacityAudit }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CapacityError {
#[error("execution capacity ratio must be finite and in (0, 1]")]
InvalidRatio,
#[error("execution capacity decimal cannot be represented exactly")]
InvalidDecimal,
#[error("execution capacity observation has invalid time bounds")]
InvalidWindow,
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
#[error("execution capacity observation belongs to another session")]
WrongSession,
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
MissingObservation,
#[error("completed_bar capacity requires declared bar end and availability; an undated daily total is not a completed observation")]
MissingCompletedBar,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapacityAuditSummary {
pub mode: VolumeCapacityMode,
pub enabled: bool,
pub participation_rate: f64,
pub audited_symbol_sessions: usize,
pub failed_symbol_sessions: usize,
pub audit_passed: Option<bool>,
pub execution_time_capacity_proven: bool,
}
impl CapacityAuditSummary {
pub fn observe(&mut self, audit: &SessionCapacityAudit) {
self.audited_symbol_sessions += 1;
self.failed_symbol_sessions += usize::from(!audit.passed);
self.audit_passed = Some(self.failed_symbol_sessions == 0);
}
}
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
#[derive(Debug, Clone, Copy)]
pub struct ParticipationRate {
numerator: u128,
denominator: u128,
}
impl ParticipationRate {
pub fn new(rate: f64) -> Result<Self, CapacityError> {
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
return Err(CapacityError::InvalidRatio);
}
if rate < 1e-20 {
// Even u64::MAX shares at this rate cannot admit a single share.
return Ok(Self { numerator: 0, denominator: 1 });
}
if rate == 1.0 {
return Ok(Self { numerator: 1, denominator: 1 });
}
let text = rate.to_string();
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
let digits = digits.trim_end_matches('0');
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
if numerator > u128::MAX / u128::from(u64::MAX) {
return Err(CapacityError::InvalidDecimal);
}
Ok(Self { numerator, denominator })
}
pub fn total_shares(self, market_shares: u64) -> u64 {
let total = u128::from(market_shares) * self.numerator / self.denominator;
u64::try_from(total).expect("participation rate cannot exceed the market shares")
}
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VolumeObservationKind {
TradeIncrement,
CompletedBar,
CumulativeSession,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct VolumeObservation {
pub kind: VolumeObservationKind,
pub start: NaiveDateTime,
pub end: NaiveDateTime,
pub available_at: NaiveDateTime,
pub shares: u64,
}
impl VolumeObservation {
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
if self.start > self.end || self.available_at < self.end {
return Err(CapacityError::InvalidWindow);
}
if self.available_at > execution_at {
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
}
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
return Err(CapacityError::WrongSession);
}
Ok(self.shares)
}
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionCapacityAudit {
pub date: NaiveDate,
pub symbol: String,
pub filled_shares: u64,
pub session_shares: u64,
pub allowed_shares: u64,
pub passed: bool,
}
impl SessionCapacityAudit {
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
let allowed_shares = rate.total_shares(session_shares);
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
assert!(ParticipationRate::new(rate).is_err());
}
}
#[test]
fn completed_volume_cannot_be_used_for_an_earlier_open() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let opening = day.and_hms_opt(9,30,0).unwrap();
let closing = day.and_hms_opt(15,0,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
}
#[test]
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
}
#[test]
fn session_audit_changes_verdict_not_executed_quantity() {
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
let rate = ParticipationRate::new(0.25).unwrap();
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
assert!(!a.passed); assert!(b.passed);
assert_eq!(a.filled_shares,b.filled_shares);
}
}
@@ -0,0 +1,61 @@
use chrono::NaiveDateTime;
use crate::engine::BacktestError;
/// Cumulative TWAP entitlement depends on the frozen clock, not future quotes.
pub(crate) struct TwapSchedule {
start: NaiveDateTime,
end: NaiveDateTime,
quantity: u32,
}
impl TwapSchedule {
pub(crate) fn new(
start: Option<NaiveDateTime>,
end: Option<NaiveDateTime>,
quantity: u32,
) -> Result<Self, BacktestError> {
let (Some(start), Some(end)) = (start, end) else {
return Err(BacktestError::Execution("TWAP requires an explicit start and end clock".into()));
};
if end < start || start.date() != end.date() {
return Err(BacktestError::Execution("TWAP requires an ordered same-session clock window".into()));
}
Ok(Self { start, end, quantity })
}
pub(crate) fn due_quantity(&self, at: NaiveDateTime, filled: u32) -> u32 {
if at < self.start {
return 0;
}
let entitlement = if at >= self.end {
self.quantity
} else {
let elapsed = (at - self.start).num_microseconds().expect("same-day interval") as u128;
let duration = (self.end - self.start).num_microseconds().expect("same-day interval") as u128;
(u128::from(self.quantity) * elapsed / duration) as u32
};
entitlement.saturating_sub(filled)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, NaiveDate};
#[test]
fn clock_entitlements_are_exact_and_allow_backlog_without_future_quote_counts() {
let start = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(10, 0, 0).unwrap();
let end = start + Duration::minutes(10);
let schedule = TwapSchedule::new(Some(start), Some(end), 1_000).unwrap();
assert_eq!(schedule.due_quantity(start, 0), 0);
assert_eq!(schedule.due_quantity(start + Duration::minutes(2), 0), 200);
assert_eq!(schedule.due_quantity(start + Duration::minutes(5), 100), 400);
assert_eq!(schedule.due_quantity(end, 100), 900);
assert_eq!(schedule.due_quantity(end, 1_000), 0);
assert!(TwapSchedule::new(Some(start), None, 1_000).is_err());
assert!(TwapSchedule::new(Some(end), Some(start), 1_000).is_err());
assert_eq!(TwapSchedule::new(Some(start), Some(start), 1_000).unwrap().due_quantity(start, 0), 1_000);
}
}
@@ -0,0 +1,52 @@
//! Indicator metadata is versioned independently from the numerical kernel.
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
use serde_json::{Value, json};
use ta_lib::abstract_api::{self, OptInputType};
pub fn catalog() -> Value {
use sha2::{Digest, Sha256};
let mut implementation = Sha256::new();
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
let implementation_sha256=format!("{:x}",implementation.finalize());
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
})).collect();
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
"execution_context_contract":crate::pattern_context::CONTRACT,
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
"market_event_context_contract":crate::market_event_context::CONTRACT,
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
}
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
match kind {
OptInputType::IntegerRange { min, max, default, .. } => json!({
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
}),
OptInputType::RealRange { min, max, default, precision, .. } => json!({
"value_type":"number", "minimum":min, "maximum":max,
"default":default, "display_precision":precision,
}),
OptInputType::IntegerList { values, default } => json!({
"value_type":"integer", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
OptInputType::RealList { values, default } => json!({
"value_type":"number", "default":default,
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
}),
}
}
+5 -144
View File
@@ -1,7 +1,7 @@
//! Causal, typed indicator/event expressions shared by research and trading.
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::Value;
use std::collections::BTreeMap;
use ta_lib::{
Core,
@@ -82,7 +82,7 @@ pub struct Series {
pub values: Vec<Option<f64>>,
}
const OPERATORS: &[&str] = &[
pub(crate) const OPERATORS: &[&str] = &[
"GT",
"GTE",
"LT",
@@ -168,27 +168,7 @@ const OPERATORS: &[&str] = &[
"IF",
];
pub fn catalog() -> Value {
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
})).collect();
json!({"contract":CONTRACT,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
"execution_context_contract":crate::pattern_context::CONTRACT,
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
"market_event_context_contract":crate::market_event_context::CONTRACT,
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
}
pub use crate::factor_event_catalog::catalog;
impl Frame {
pub fn validate(&self) -> Result<(), String> {
@@ -952,124 +932,5 @@ fn operator(
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(values: Vec<Option<f64>>) -> Frame {
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
let times = (0..values.len())
.map(|i| start + chrono::Duration::days(i as i64))
.collect::<Vec<_>>();
Frame {
symbol: "TEST".into(),
frequency: "1d".into(),
decision_at: *times.last().unwrap(),
available_at: times.clone(),
timestamps: times,
fields: BTreeMap::from([("close".into(), values)]),
}
}
fn expr(v: Value) -> Expr {
serde_json::from_value(v).unwrap()
}
#[test]
fn ta_sma_real_values_and_parameter_validation() {
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
);
assert_eq!(
evaluate(&e, &frame).unwrap().values,
vec![None, None, Some(2.0), Some(3.0)]
);
let bad = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
);
assert!(
evaluate(&bad, &frame)
.unwrap_err()
.contains("parameter_unknown")
);
}
#[test]
fn cross_is_event_not_state_and_never_uses_future() {
let f = frame(vec![
Some(9.0),
Some(10.0),
Some(11.0),
Some(12.0),
Some(8.0),
]);
let e = expr(
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
);
let mut invalid = f.clone();
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
assert!(evaluate(&e, &invalid).is_err());
}
#[test]
fn missing_is_not_zero_and_breakout_excludes_current() {
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
let e = expr(
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, None, Some(1.0), None, None]
);
let zero = expr(
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
);
assert!(
evaluate(&zero, &f)
.unwrap()
.values
.iter()
.all(Option::is_none)
);
}
#[test]
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(1.0), None, None, Some(2.0)]
);
let e = expr(
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
}
#[test]
fn no_event_has_no_bars_since_and_type_errors_reject() {
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
assert!(
evaluate(
&expr(
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
),
&f
)
.is_err()
);
}
#[test]
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
assert!(
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
.is_err()
);
let c = catalog();
assert!(c["indicators"].as_array().unwrap().len() > 190);
assert_eq!(c["live_routing"], false);
}
}
#[path = "factor_events_tests.rs"]
mod tests;
+161
View File
@@ -0,0 +1,161 @@
use super::*;
use serde_json::json;
use crate::factor_event_catalog::parameter_domain;
#[test]
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
for function in abstract_api::funcs() {
let handle = abstract_api::get_func_handle(function.name).unwrap();
let core = Core::new();
let mut call = handle.new_call(&core);
for (index, parameter) in function.opt_inputs.iter().enumerate() {
let domain = parameter_domain(parameter.kind);
let default = domain["default"].as_f64().unwrap();
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
if let Some(choices) = domain.get("choices") {
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
} else {
assert!(default >= domain["minimum"].as_f64().unwrap());
assert!(default <= domain["maximum"].as_f64().unwrap());
}
if domain["value_type"] == "integer" {
assert_eq!(default.fract(), 0.0);
call.set_opt(index, default as i32).unwrap();
} else {
call.set_opt(index, default).unwrap();
}
}
assert!(call.lookback().is_ok(), "{}", function.name);
}
}
#[test]
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
let catalog = catalog();
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
let indicators = catalog["indicators"].as_array().unwrap();
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
}
fn frame(values: Vec<Option<f64>>) -> Frame {
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
let times = (0..values.len())
.map(|i| start + chrono::Duration::days(i as i64))
.collect::<Vec<_>>();
Frame {
symbol: "TEST".into(),
frequency: "1d".into(),
decision_at: *times.last().unwrap(),
available_at: times.clone(),
timestamps: times,
fields: BTreeMap::from([("close".into(), values)]),
}
}
fn expr(v: Value) -> Expr {
serde_json::from_value(v).unwrap()
}
#[test]
fn ta_sma_real_values_and_parameter_validation() {
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
);
assert_eq!(
evaluate(&e, &frame).unwrap().values,
vec![None, None, Some(2.0), Some(3.0)]
);
let bad = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
);
assert!(
evaluate(&bad, &frame)
.unwrap_err()
.contains("parameter_unknown")
);
}
#[test]
fn cross_is_event_not_state_and_never_uses_future() {
let f = frame(vec![
Some(9.0),
Some(10.0),
Some(11.0),
Some(12.0),
Some(8.0),
]);
let e = expr(
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
);
let mut invalid = f.clone();
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
assert!(evaluate(&e, &invalid).is_err());
}
#[test]
fn missing_is_not_zero_and_breakout_excludes_current() {
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
let e = expr(
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, None, Some(1.0), None, None]
);
let zero = expr(
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
);
assert!(
evaluate(&zero, &f)
.unwrap()
.values
.iter()
.all(Option::is_none)
);
}
#[test]
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
let e = expr(
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
);
assert_eq!(
evaluate(&e, &f).unwrap().values,
vec![None, Some(1.0), None, None, Some(2.0)]
);
let e = expr(
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
);
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
}
#[test]
fn no_event_has_no_bars_since_and_type_errors_reject() {
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
assert!(
evaluate(
&expr(
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
),
&f
)
.is_err()
);
}
#[test]
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
assert!(
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
.is_err()
);
let c = catalog();
assert!(c["indicators"].as_array().unwrap().len() > 190);
assert_eq!(c["live_routing"], false);
}
+369
View File
@@ -0,0 +1,369 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::TradingCalendar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TradingActionOrigin {
Strategy,
Manual,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomaticTradeProtection {
#[serde(default, deserialize_with = "optional_days")]
pub buy_protection_days: u32,
#[serde(default, deserialize_with = "optional_days")]
pub sell_cooldown_days: u32,
#[serde(default, deserialize_with = "optional_days")]
pub max_holding_days: u32,
#[serde(default, deserialize_with = "optional_locks")]
pub locks: Vec<AutomaticTradeLock>,
}
pub fn deserialize_optional_policy<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<AutomaticTradeProtection, D::Error> {
Ok(Option::<AutomaticTradeProtection>::deserialize(deserializer)?.unwrap_or_default())
}
fn optional_days<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
let raw = serde_json::Value::deserialize(deserializer)?;
if raw.is_null() {
return Ok(0);
}
raw.as_f64()
.filter(|value| {
value.is_finite() && value.fract() == 0.0 && *value >= 0.0 && *value <= 3650.0
})
.map(|value| value as u32)
.ok_or_else(|| serde::de::Error::custom("protection days must be integers in 0..3650"))
}
fn optional_locks<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<AutomaticTradeLock>, D::Error> {
Ok(Option::<Vec<AutomaticTradeLock>>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomaticTradeLock {
pub symbol: String,
pub start_date: NaiveDate,
pub end_date: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HoldingLifecycleEvidence {
pub has_position: bool,
pub opened_date: Option<NaiveDate>,
pub last_buy_date: Option<NaiveDate>,
pub last_sell_date: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AutomaticTradePermission {
pub buy_denial: Option<&'static str>,
pub sell_denial: Option<&'static str>,
pub max_holding_exit: bool,
}
impl AutomaticTradeProtection {
pub fn enabled(&self) -> bool {
self.buy_protection_days > 0
|| self.sell_cooldown_days > 0
|| self.max_holding_days > 0
|| !self.locks.is_empty()
}
pub fn validate(&self) -> Result<(), String> {
if [
self.buy_protection_days,
self.sell_cooldown_days,
self.max_holding_days,
]
.into_iter()
.any(|days| days > 3650)
{
return Err("automatic_trade_holding_days_out_of_range: expected 0..3650".into());
}
if self.locks.len() > 2000 {
return Err("automatic_trade_locks_limit: maximum 2000 intervals".into());
}
for lock in &self.locks {
let valid_symbol = lock.symbol.split_once('.').is_some_and(|(code, venue)| {
code.len() == 6
&& code.bytes().all(|ch| ch.is_ascii_digit())
&& matches!(venue, "SH" | "SZ" | "BJ")
});
if !valid_symbol {
return Err(format!(
"automatic_trade_lock_invalid_symbol: {}",
lock.symbol
));
}
if lock.end_date.is_some_and(|end| end < lock.start_date) {
return Err(format!(
"automatic_trade_lock_invalid_interval: {}",
lock.symbol
));
}
}
Ok(())
}
pub fn evaluate(
&self,
symbol: &str,
execution_date: NaiveDate,
evidence: &HoldingLifecycleEvidence,
calendar: &TradingCalendar,
) -> Result<AutomaticTradePermission, String> {
self.validate()?;
if self.locks.iter().any(|lock| {
lock.symbol == symbol
&& lock.start_date <= execution_date
&& lock.end_date.is_none_or(|end| execution_date <= end)
}) {
return Ok(AutomaticTradePermission {
buy_denial: Some("automatic_trade_locked"),
sell_denial: Some("automatic_trade_locked"),
max_holding_exit: false,
});
}
let elapsed = |date: NaiveDate| -> Result<usize, String> {
let start = calendar.index_of(date).ok_or_else(|| {
format!(
"automatic_trade_holding_calendar_missing: symbol={symbol} fact_date={date}"
)
})?;
let end = calendar.index_of(execution_date).ok_or_else(|| format!("automatic_trade_holding_calendar_missing: symbol={symbol} execution_date={execution_date}"))?;
end.checked_sub(start).ok_or_else(|| format!("automatic_trade_holding_future_fact: symbol={symbol} fact_date={date} execution_date={execution_date}"))
};
let mut decision = AutomaticTradePermission::default();
if self.buy_protection_days > 0
&& evidence.has_position
&& let Some(date) = evidence.last_buy_date
&& elapsed(date)? <= self.buy_protection_days as usize
{
decision.sell_denial = Some("buy_fill_protection");
}
if self.sell_cooldown_days > 0
&& let Some(date) = evidence.last_sell_date
&& elapsed(date)? <= self.sell_cooldown_days as usize
{
decision.buy_denial = Some("sell_fill_cooldown");
}
if self.max_holding_days > 0 && evidence.has_position {
let opened = evidence.opened_date.ok_or_else(|| format!("automatic_trade_opened_date_missing: symbol={symbol}; require confirmed position lifecycle evidence"))?;
decision.max_holding_exit = elapsed(opened)? >= self.max_holding_days as usize
&& decision.sell_denial.is_none();
if decision.max_holding_exit {
decision.buy_denial = Some("maximum_holding_exit");
}
}
Ok(decision)
}
/// The caller supplies origin from its authenticated execution path, never
/// from an untrusted order-body flag. Broker and ordinary risk checks remain.
pub fn evaluate_for_origin(
&self,
origin: TradingActionOrigin,
symbol: &str,
execution_date: NaiveDate,
evidence: &HoldingLifecycleEvidence,
calendar: &TradingCalendar,
) -> Result<AutomaticTradePermission, String> {
self.validate()?;
match origin {
TradingActionOrigin::Strategy => {
self.evaluate(symbol, execution_date, evidence, calendar)
}
TradingActionOrigin::Manual => Ok(AutomaticTradePermission::default()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(value: &str) -> NaiveDate {
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
}
fn calendar() -> TradingCalendar {
TradingCalendar::new(
[
"2026-09-11",
"2026-09-14",
"2026-09-15",
"2026-09-16",
"2026-09-17",
]
.into_iter()
.map(d)
.collect(),
)
}
#[test]
fn three_complete_sessions_protect_through_wednesday_not_72_hours() {
let policy = AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
};
let evidence = HoldingLifecycleEvidence {
has_position: true,
last_buy_date: Some(d("2026-09-11")),
last_sell_date: Some(d("2026-09-11")),
..Default::default()
};
for day in ["2026-09-11", "2026-09-14", "2026-09-15", "2026-09-16"] {
let decision = policy
.evaluate("000001.SZ", d(day), &evidence, &calendar())
.unwrap();
assert_eq!(decision.sell_denial, Some("buy_fill_protection"));
assert_eq!(decision.buy_denial, Some("sell_fill_cooldown"));
}
assert_eq!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap(),
AutomaticTradePermission::default()
);
}
#[test]
fn absolute_locks_are_inclusive_and_override_timed_exit_without_changing_other_symbols() {
let policy = AutomaticTradeProtection {
max_holding_days: 1,
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d("2026-09-11"),
end_date: Some(d("2026-09-16")),
}],
..Default::default()
};
let evidence = HoldingLifecycleEvidence {
has_position: true,
opened_date: Some(d("2026-09-11")),
..Default::default()
};
let locked = policy
.evaluate("000001.SZ", d("2026-09-16"), &evidence, &calendar())
.unwrap();
assert_eq!(locked.sell_denial, Some("automatic_trade_locked"));
assert!(!locked.max_holding_exit);
assert!(
policy
.evaluate("600000.SH", d("2026-09-16"), &evidence, &calendar())
.unwrap()
.max_holding_exit
);
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap()
.max_holding_exit
);
}
#[test]
fn zero_disabled_and_missing_calendar_or_opened_date_are_not_inferred() {
let evidence = HoldingLifecycleEvidence {
has_position: true,
..Default::default()
};
assert_eq!(
AutomaticTradeProtection::default()
.evaluate(
"000001.SZ",
d("2026-09-17"),
&evidence,
&TradingCalendar::new(vec![])
)
.unwrap(),
AutomaticTradePermission::default()
);
let policy = AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap_err()
.contains("opened_date_missing")
);
let evidence = HoldingLifecycleEvidence {
opened_date: Some(d("2026-09-10")),
..evidence
};
assert!(
policy
.evaluate("000001.SZ", d("2026-09-17"), &evidence, &calendar())
.unwrap_err()
.contains("calendar_missing")
);
}
#[test]
fn manual_origin_only_bypasses_automatic_policy_not_an_order_or_broker_permission() {
let policy = AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d("2026-09-11"),
end_date: None,
}],
..Default::default()
};
assert_eq!(
policy
.evaluate_for_origin(
TradingActionOrigin::Manual,
"000001.SZ",
d("2026-09-14"),
&HoldingLifecycleEvidence::default(),
&calendar()
)
.unwrap(),
AutomaticTradePermission::default()
);
assert_eq!(
policy
.evaluate_for_origin(
TradingActionOrigin::Strategy,
"000001.SZ",
d("2026-09-14"),
&HoldingLifecycleEvidence::default(),
&calendar()
)
.unwrap()
.buy_denial,
Some("automatic_trade_locked")
);
}
#[test]
fn opening_date_follows_fills_not_partial_sales_or_corporate_conversions() {
let mut portfolio = crate::PortfolioState::new(100_000.0);
let position = portfolio.position_mut("000001.SZ");
position.buy(d("2026-09-11"), 100, 10.0);
position.buy(d("2026-09-14"), 200, 10.0);
position.sell(100, 10.0).unwrap();
assert_eq!(position.opened_date(), Some(d("2026-09-11")));
portfolio
.apply_successor_conversion("000001.SZ", "000002.SZ", 2.0, 0.0)
.unwrap();
let successor = portfolio.position_mut("000002.SZ");
assert_eq!(successor.opened_date(), Some(d("2026-09-11")));
assert_eq!(successor.last_buy_date(), Some(d("2026-09-14")));
successor.sell(400, 5.0).unwrap();
assert_eq!(successor.opened_date(), None);
successor.buy(d("2026-09-17"), 100, 5.0);
assert_eq!(successor.opened_date(), Some(d("2026-09-17")));
}
}
+36 -2
View File
@@ -27,6 +27,12 @@ pub struct Instrument {
}
impl Instrument {
/// Classification from the admitted security master, never a code prefix
/// or a name substring. This does not grant T+0 settlement eligibility.
pub fn is_exchange_traded_fund(&self) -> bool {
matches!(self.board.trim().to_ascii_uppercase().as_str(), "ETF" | "EXCHANGE_TRADED_FUND")
}
pub fn effective_round_lot(&self) -> u32 {
self.round_lot.max(1)
}
@@ -70,8 +76,17 @@ impl Instrument {
pub fn is_active_on(&self, date: NaiveDate) -> bool {
self.listed_at.is_none_or(|listed_at| listed_at <= date)
&& !self.is_delisted_before(date)
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none())
&& !self.is_delisted_on_or_before(date)
}
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
if self.listed_at.is_some_and(|listed| date < listed) {
Some("not_yet_listed")
} else if self.is_delisted_on_or_before(date) {
Some("delisted")
} else {
None
}
}
}
@@ -107,6 +122,25 @@ mod tests {
}
}
#[test]
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
let mut item = instrument("BJS", 100);
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
item.listed_at = Some(listing);
item.delisted_at = Some(removal);
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
assert!(item.is_active_on(listing));
assert!(!item.is_active_on(removal));
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
item.delisted_at = None;
for status in ["delisting", "delisted", "inactive", "terminated"] {
item.status = status.into();
assert!(item.is_active_on(listing));
assert_eq!(item.dated_market_absence_reason(listing), None);
}
}
#[test]
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
let kcb = instrument(" kSh ", 100);
+15
View File
@@ -2,10 +2,15 @@ pub mod broker;
pub mod calendar;
pub mod cost;
pub mod data;
mod numeric_factors;
pub mod daily_patterns;
pub mod pattern_context;
pub mod session_events;
pub mod factor_events;
pub mod execution_capacity;
mod etf_execution;
mod execution_schedule;
mod factor_event_catalog;
pub mod factor_cross_section;
pub mod market_event_context;
pub mod engine;
@@ -21,10 +26,20 @@ pub mod platform_runtime_schema;
pub mod platform_strategy_spec;
pub mod portfolio;
pub mod portfolio_loss;
pub mod position_exposure;
pub mod risk_control;
pub mod rules;
pub mod scheduler;
pub mod strategy;
pub mod holding_policy;
pub mod stock_pool_candidates;
pub mod stock_pool_indicators;
pub mod stock_pool_execution;
pub mod stock_pool_index_policy;
pub mod stock_pool_market_cap;
pub mod stock_pool_state;
pub mod stock_pool_quote_facts;
pub mod signal_contract;
pub mod strategy_ai;
pub mod universe;
+25 -1
View File
@@ -558,7 +558,9 @@ fn alpha_beta(
}
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
let mut peak = 0.0_f64;
// NAV is measured against the pre-period capital. The first real loss
// must not become a new zero-drawdown baseline.
let mut peak = 1.0_f64;
let mut max_drawdown = 0.0_f64;
let mut duration = 0_usize;
let mut max_duration = 0_usize;
@@ -767,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
mod tests {
use super::*;
#[test]
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
assert!((drawdown + 0.1).abs() < 1e-12);
assert_eq!(duration, 2);
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
assert_eq!(drawdown_stats(&[]), (0.0, 0));
}
#[test]
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
let curve = vec![
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
];
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
assert_eq!(metrics.total_trade_days, 2);
assert_eq!(metrics.max_drawdown_duration_days, 2);
}
fn equity_point(
date: &str,
total_equity: f64,
+415
View File
@@ -0,0 +1,415 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use std::ops::Index;
use compact_str::CompactString;
use serde::de::{MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
#[derive(Clone, Default, PartialEq)]
pub struct NumericFactorMap {
entries: Vec<(CompactString, f64)>,
}
fn compact_key(key: Cow<'static, str>) -> CompactString {
match key {
Cow::Borrowed(value) => CompactString::const_new(value),
Cow::Owned(value) => CompactString::from(value),
}
}
impl NumericFactorMap {
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
self.entries.clear();
}
/// Reserve known new fields without geometric spare capacity per snapshot.
pub fn reserve_exact(&mut self, additional: usize) {
self.entries.reserve_exact(additional);
}
pub fn get(&self, key: &str) -> Option<&f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| &self.entries[index].1)
}
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| &mut self.entries[index].1)
}
pub fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
self.insert_compact(compact_key(key), value)
}
pub fn insert_compact(&mut self, key: CompactString, value: f64) -> Option<f64> {
if self
.entries
.last()
.is_none_or(|(last, _)| last.as_str() < key.as_str())
{
self.entries.push((key, value));
return None;
}
match self
.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key.as_str()))
{
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
Err(index) => {
self.entries.insert(index, (key, value));
None
}
}
}
pub fn remove(&mut self, key: &str) -> Option<f64> {
self.entries
.binary_search_by(|(name, _)| name.as_str().cmp(key))
.ok()
.map(|index| self.entries.remove(index).1)
}
pub fn retain(&mut self, mut keep: impl FnMut(&CompactString, &mut f64) -> bool) {
self.entries.retain_mut(|(key, value)| keep(key, value));
}
pub fn iter(&self) -> Iter<'_> {
Iter(self.entries.iter())
}
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &CompactString> + ExactSizeIterator {
self.entries.iter().map(|(key, _)| key)
}
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
self.entries.iter().map(|(_, value)| value)
}
}
impl fmt::Debug for NumericFactorMap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map().entries(self).finish()
}
}
impl Index<&str> for NumericFactorMap {
type Output = f64;
fn index(&self, key: &str) -> &Self::Output {
self.get(key).expect("numeric factor key not found")
}
}
pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
impl<'a> Iterator for Iter<'a> {
type Item = (&'a CompactString, &'a f64);
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|(k, v)| (k, v))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl DoubleEndedIterator for Iter<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
self.0.next_back().map(|(k, v)| (k, v))
}
}
impl ExactSizeIterator for Iter<'_> {}
impl<'a> IntoIterator for &'a NumericFactorMap {
type Item = (&'a CompactString, &'a f64);
type IntoIter = Iter<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl IntoIterator for NumericFactorMap {
type Item = (CompactString, f64);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self {
iter.into_iter().map(|(key, value)| (compact_key(key), value)).collect()
}
}
impl FromIterator<(CompactString, f64)> for NumericFactorMap {
fn from_iter<T: IntoIterator<Item = (CompactString, f64)>>(iter: T) -> Self {
let mut entries: Vec<_> = iter.into_iter().collect();
// Stable sorting preserves last-value-wins for repeated input keys.
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
entries.sort_by(|left, right| left.0.cmp(&right.0));
}
entries.dedup_by(|later, earlier| {
if later.0 == earlier.0 {
earlier.1 = later.1;
true
} else {
false
}
});
Self { entries }
}
}
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
self.extend(iter.into_iter().map(|(key, value)| (compact_key(key), value)));
}
}
impl Extend<(CompactString, f64)> for NumericFactorMap {
fn extend<T: IntoIterator<Item = (CompactString, f64)>>(&mut self, iter: T) {
let mut incoming: Self = iter.into_iter().collect();
if incoming.is_empty() {
return;
}
if self.is_empty() {
*self = incoming;
return;
}
if self.entries.last().unwrap().0 < incoming.entries[0].0 {
self.entries.append(&mut incoming.entries);
return;
}
// Merge sorted sets in linear time; wide factor batches must not shift
// the existing vector once per field. Existing keys keep their identity.
let mut merged = Vec::with_capacity(self.len() + incoming.len());
let mut old = std::mem::take(&mut self.entries).into_iter().peekable();
let mut new = incoming.entries.into_iter().peekable();
while let (Some(left), Some(right)) = (old.peek(), new.peek()) {
match left.0.cmp(&right.0) {
std::cmp::Ordering::Less => merged.push(old.next().unwrap()),
std::cmp::Ordering::Greater => merged.push(new.next().unwrap()),
std::cmp::Ordering::Equal => {
let (key, _) = old.next().unwrap();
merged.push((key, new.next().unwrap().1));
}
}
}
merged.extend(old);
merged.extend(new);
self.entries = merged;
}
}
impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
fn from(entries: [(Cow<'static, str>, f64); N]) -> Self {
entries.into_iter().collect()
}
}
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
entries.into_iter().collect()
}
}
impl Serialize for NumericFactorMap {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.len()))?;
for (key, value) in self {
map.serialize_entry(key, value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for NumericFactorMap {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Fields;
impl<'de> Visitor<'de> for Fields {
type Value = NumericFactorMap;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a numeric factor map")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut entries = Vec::new();
while let Some((key, value)) = map.next_entry::<CompactString, f64>()? {
entries.push((key, value));
}
Ok(entries.into_iter().collect())
}
}
deserializer.deserialize_map(Fields)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_reservation_preserves_values_and_avoids_growth_during_known_inserts() {
let mut map = NumericFactorMap::from([
(Cow::Borrowed("amount"), 125.25),
(Cow::Borrowed("nullable_value"), f64::from_bits(0x7ff8000000000042)),
(Cow::Borrowed("signal"), -0.0),
]);
let original = map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>();
map.reserve_exact(2);
assert_eq!(map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>(), original);
let buffer = map.entries.as_ptr();
map.insert(Cow::Borrowed("pre_close"), 12.5);
map.insert(Cow::Borrowed("no_limit"), 0.0);
assert_eq!(map.entries.as_ptr(), buffer);
assert_eq!(map.len(), 5);
assert_eq!(map["signal"].to_bits(), (-0.0_f64).to_bits());
assert_eq!(map["nullable_value"].to_bits(), 0x7ff8000000000042);
let before = map.entries.as_ptr();
map.reserve_exact(0);
assert_eq!(map.entries.as_ptr(), before);
}
#[test]
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
let map = NumericFactorMap::from([
(Cow::Owned("dynamic_factor_20".to_owned()), -0.0),
(Cow::Borrowed(LONG), 1.0),
]);
let cloned = map.clone();
let short = cloned.keys().find(|key| key.as_str() == "dynamic_factor_20").unwrap();
assert!(!short.is_heap_allocated());
let long = cloned.keys().find(|key| key.as_str() == LONG).unwrap();
assert_eq!(long.as_static_str(), Some(LONG));
assert_eq!(cloned["dynamic_factor_20"].to_bits(), (-0.0_f64).to_bits());
assert_eq!(std::mem::size_of::<(CompactString, f64)>(), std::mem::size_of::<(Cow<'static, str>, f64)>());
}
#[test]
fn long_dynamic_unicode_and_short_keys_keep_the_same_json_map() {
let entries = ["", "a", "a_field_longer_than_the_inline_string_capacity", "价格因子", "ths_up_days_stock"]
.into_iter().enumerate().map(|(index, key)| (Cow::Owned(key.to_string()), index as f64 + 0.25))
.collect::<BTreeMap<_, _>>();
let map = NumericFactorMap::from(entries.clone());
assert_eq!(serde_json::to_string(&map).unwrap(), serde_json::to_string(&entries).unwrap());
let decoded: NumericFactorMap = serde_json::from_str(&serde_json::to_string(&map).unwrap()).unwrap();
assert_eq!(decoded, map);
assert!(!decoded.keys().find(|key| key.as_str() == "ths_up_days_stock").unwrap().is_heap_allocated());
}
#[test]
fn updates_order_removal_and_values_match_tree_map() {
let mut flat = NumericFactorMap::new();
let mut tree = BTreeMap::new();
let mut seed = 71_u64;
for index in 0..10000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let key: Cow<'static, str> = Cow::Owned(format!("factor_{:04}", (seed >> 32) % 1000));
let value = (index as f64 - 5000.0) / 7.0;
if index % 11 == 0 {
assert_eq!(flat.remove(key.as_ref()), tree.remove(key.as_ref()));
} else {
assert_eq!(flat.insert(key.clone(), value), tree.insert(key, value));
}
}
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
flat.retain(|_, value| *value > 100.0);
tree.retain(|_, value| *value > 100.0);
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(
std::mem::size_of::<NumericFactorMap>(),
std::mem::size_of_val(&tree)
);
}
#[test]
fn bulk_load_is_sorted_and_keeps_last_value_for_each_field() {
let input = vec![
(Cow::Borrowed("z"), 2.0),
(Cow::Borrowed("a"), 1.0),
(Cow::Borrowed("z"), 3.0),
(Cow::Borrowed("z"), 4.0),
];
let flat: NumericFactorMap = input.clone().into_iter().collect();
let tree: BTreeMap<_, _> = input.into_iter().collect();
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(flat["z"], 4.0);
}
#[test]
fn serialization_keeps_the_map_contract_and_precise_numbers() {
let input = [
(Cow::Borrowed("zero"), -0.0),
(Cow::Borrowed("tiny"), 1.0000000000000002),
(Cow::Borrowed("large"), 9.123456789123456e20),
];
let flat = NumericFactorMap::from(input.clone());
let tree = BTreeMap::from(input);
let json = serde_json::to_string(&flat).unwrap();
assert_eq!(json, serde_json::to_string(&tree).unwrap());
let decoded: NumericFactorMap = serde_json::from_str(&json).unwrap();
for (key, value) in &flat {
assert_eq!(value.to_bits(), decoded[key.as_ref()].to_bits());
}
let duplicate: NumericFactorMap = serde_json::from_str(r#"{"x":1,"x":2}"#).unwrap();
assert_eq!(duplicate["x"], 2.0);
}
#[test]
fn clone_does_not_share_mutable_values() {
let original = NumericFactorMap::from([(Cow::Borrowed("signal"), 1.0)]);
let mut copy = original.clone();
*copy.get_mut("signal").unwrap() = 0.0;
copy.insert(Cow::Borrowed("other"), 2.0);
assert_eq!(original["signal"], 1.0);
assert!(!original.contains_key("other"));
}
#[test]
fn wide_batch_merge_matches_tree_and_preserves_old_key_ownership() {
let entries = (0..4096)
.map(|index| (Cow::Owned(format!("f_{index:05}")), index as f64))
.collect::<Vec<_>>();
let mut flat: NumericFactorMap = entries.clone().into_iter().collect();
let mut tree = BTreeMap::from_iter(entries);
flat.insert(Cow::Borrowed("shared"), -0.0);
tree.insert(Cow::Borrowed("shared"), -0.0);
let incoming = (2048..8192)
.rev()
.map(|index| (Cow::Owned(format!("f_{index:05}")), -(index as f64)))
.chain([(Cow::Owned("shared".to_owned()), 1.0)])
.collect::<Vec<_>>();
flat.extend(incoming.clone());
tree.extend(incoming);
assert_eq!(
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
);
assert_eq!(flat.keys().last().map(CompactString::as_str), Some("shared"));
assert!(!flat.keys().last().unwrap().is_heap_allocated());
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
assert!(flat["zz"].is_nan());
flat.extend(std::iter::empty::<(CompactString, f64)>());
assert_eq!(flat.len(), tree.len() + 1);
}
}
+41 -3
View File
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
}
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
// A runner bundle also contains source/extract copies. Follow the same
// authoritative spec selection as the execution loader, not those copies.
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
return specs_in_value(spec);
}
let mut specs = Vec::new();
match value {
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
@@ -221,7 +226,15 @@ pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
}
}
Value::Object(items) => {
for v in items.values() {
let typed_pool = items.get("stockPool").or_else(|| items.get("stock_pool"))
.is_some_and(Value::is_object);
for (key, v) in items {
// The executable pool already supplies decoded expressions.
// Its display/source serialization escapes those expressions
// one more time and is not another executable program.
if typed_pool && matches!(key.as_str(), "sourceCode" | "source_code") {
continue;
}
specs.extend(specs_in_value(v)?);
}
}
@@ -248,6 +261,31 @@ mod tests {
use super::*;
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
use serde_json::json;
#[test]
fn structured_pool_conditions_are_not_rescanned_inside_serialized_source_code() {
let pattern = json!({"template":"expression","parameters":{"history_window":20},
"expression":{"kind":"operator","name":"GT","args":[{"kind":"field","name":"amount"},{"kind":"number","value":0}]}});
let expr = format!("pattern_signal({})", serde_json::to_string(&pattern.to_string()).unwrap());
let pool = json!({"schema_version":1,"pool_id":"fixture","version_id":"v1","members":[],
"allocation_policy":{},"timing_policy":{},"stop_take_policy":{},"out_of_pool_policy":"hold",
"exit_signals":[{"role":"risk_exit","when_expr":expr,"remaining_position_bps":5000,"reason":"fixture"}]});
let source = format!("stock_pool.config({pool})");
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
assert_eq!(specs_in_value(&value).unwrap().len(), 2);
for wrapper in ["strategySpec", "strategy_spec"] {
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
"strategy_extract":{"parameters":{"source_code":source}}});
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
}
let mut invalid = value.clone();
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
}
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
}
#[test]
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
@@ -298,7 +336,7 @@ mod tests {
][n][i];
market.push(DailyMarketSnapshot {
date: *d,
symbol: s.to_string(),
symbol: (*s).into(),
timestamp: None,
day_open: c,
open: c,
@@ -321,7 +359,7 @@ mod tests {
});
factors.push(DailyFactorSnapshot {
date: *d,
symbol: s.to_string(),
symbol: (*s).into(),
market_cap_bn: 1.,
free_float_cap_bn: 1.,
pe_ttm: 10.,
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
//! Stock pools emit one frozen framework intent, not a lossy code-strategy translation.
use super::*;
use crate::stock_pool_execution as pool;
use rust_decimal::Decimal;
impl PlatformExprStrategy {
pub(super) fn stock_pool_decision(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, BacktestError> {
let program = self
.config
.stock_pool
.as_ref()
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
.clone();
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|| self.config.position_target_rules.len() != program.exit_signals.len()
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
{
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
}
let mut constraints = pool::stock_pool_constraints_from_configuration(
&program.allocation_policy,
&program.stop_take_policy,
)
.map_err(BacktestError::Execution)?;
if let Some(policy) = constraints
.market_timing_policy
.as_ref()
.filter(|policy| policy.enabled)
{
let before_close = !ctx.is_lagged_execution()
&& ctx
.active_datetime
.is_some_and(|at| at.time() < NaiveTime::from_hms_opt(15, 0, 0).unwrap());
let as_of = if before_close {
ctx.data
.previous_trading_date(ctx.decision_date, 1)
.ok_or_else(|| {
BacktestError::Execution(
"market_timing_previous_completed_session_missing".into(),
)
})?
} else {
ctx.decision_date
};
let required = policy
.required_history()
.map_err(BacktestError::Execution)?;
let mut dates = ctx
.data
.calendar()
.iter()
.filter(|date| *date <= as_of)
.collect::<Vec<_>>();
if dates.len() < required {
return Err(BacktestError::Execution(format!(
"market_timing_official_calendar_incomplete:required={required}:available={}",
dates.len()
)));
}
dates = dates.split_off(dates.len() - required);
let index = policy.index_code.as_ref().expect("validated index policy");
let closes = dates
.iter()
.map(|date| {
let row = ctx.data.market(*date, index).ok_or_else(|| {
BacktestError::Execution(format!(
"market_timing_completed_index_row_missing:{index}:{date}"
))
})?;
Ok(crate::stock_pool_index_policy::IndexClose {
date: *date,
close: row.close,
})
})
.collect::<Result<Vec<_>, BacktestError>>()?;
constraints.market_timing_input =
Some(crate::stock_pool_index_policy::MarketTimingInput {
index_code: index.clone(),
as_of_date: as_of,
official_dates: dates,
closes,
});
}
let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
Some(&program.timing_policy),
!self.config.buy_filter_expr.trim().is_empty(),
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
)
.map_err(BacktestError::Execution)?;
if self.config.in_skip_window(ctx.decision_date) {
return Ok(StrategyDecision::default());
}
let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly
|| [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()]
.into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str()))
.any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly);
if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time()<NaiveTime::from_hms_opt(15,0,0).unwrap()) {
for symbol in program.members.iter().map(|member|&member.symbol).chain(ctx.portfolio.positions().keys()) {
if ctx.data.instrument(symbol).is_some_and(|instrument|instrument.is_exchange_traded_fund()&&instrument.dated_market_absence_reason(ctx.execution_date).is_none())
&& self.scheduled_quote_at_time(ctx,ctx.execution_date,symbol,None).is_none()
{
return Err(BacktestError::Execution(format!("etf_intraday_condition_evidence_missing:{symbol}; completed daily references cannot make minute/tick conditions true")));
}
}
}
let day = self.day_state(ctx, ctx.decision_date)?;
let (market_date, universe_date, factor_date) = self.selection_dates(ctx);
let (low, high) = self.market_cap_band(ctx, &day)?;
let (ranked, mut diagnostics, risk_decisions) = self.select_symbols(
ctx,
market_date,
universe_date,
factor_date,
&day,
low,
high,
usize::MAX,
)?;
let held = ctx
.portfolio
.positions()
.values()
.filter(|p| p.quantity > 0)
.map(|p| p.symbol.clone())
.collect::<BTreeSet<_>>();
if !self.config.buy_filter_expr.trim().is_empty() {
for symbol in &ranked {
let stock =
self.stock_state_with_factor_date(ctx, market_date, factor_date, symbol)?;
if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? {
constraints
.buy_denials
.insert(symbol.clone(), vec!["frozen_buy_condition_not_met".into()]);
}
}
}
for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
}
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
let final_symbols = ranked
.iter()
.take(limit)
.cloned()
.collect();
let generation = format!(
"stock-pool:{}:{}:{}",
program.pool_id,
program.version_id,
ctx.active_datetime
.map(|date| date.to_string())
.unwrap_or_else(|| ctx.decision_date.to_string())
);
let selection = pool::StockPoolSelection {
trade_date: ctx.decision_date,
requested_symbols: ranked.clone(),
normal_trading_symbols: ranked.clone(),
risk_eligible_symbols: ranked.clone(),
final_symbols,
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: program.members.is_empty()
&& self.config.candidate_symbols_by_date.is_empty(),
generation: Some(generation.clone()),
};
let by_symbol = program
.members
.iter()
.map(|member| (member.symbol.as_str(), member))
.collect::<BTreeMap<_, _>>();
let mut scope = ranked.clone();
// Kept rules are execution metadata, not additional selection candidates.
for symbol in held {
if by_symbol.contains_key(symbol.as_str()) && !scope.contains(&symbol) {
scope.push(symbol)
}
}
let members = scope
.iter()
.enumerate()
.map(|(index, symbol)| {
let mut member = by_symbol
.get(symbol.as_str())
.map(|member| (*member).clone())
.unwrap_or_else(|| pool::StockPoolMemberSpec {
symbol: symbol.clone(),
requested_order: index as i32,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: constraints.default_stop_loss,
take_profit: constraints.default_take_profit,
});
member.requested_order = index as i32;
member
})
.collect();
let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?;
let ratio = self.config.position_exposure_timeline
.exposure_at(portfolio_loss_decision_at(ctx), ctx.execution_date, &self.config.position_exposure_schedule,
f64::from(base_ratio)/10000.)
.map(|value| (value * 10000.).round() as i64)
.unwrap_or(i64::from(base_ratio));
let invest_ratio_bps = i32::try_from(ratio)
.ok()
.filter(|ratio| (0..=10000).contains(ratio))
.ok_or_else(|| BacktestError::Execution("stock_pool_invest_ratio_invalid".into()))?;
let signal_equity =
self.signal_visible_total_value(ctx, ctx.decision_date, ctx.is_lagged_execution());
let frozen_equity = signal_equity
.to_string()
.parse::<Decimal>()
.map_err(|_| BacktestError::Execution("stock_pool_signal_equity_invalid".into()))?;
diagnostics.push(format!("stock_pool_signal_frozen generation={generation} candidate_count={} frozen_equity={frozen_equity}",ranked.len()));
Ok(StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(pool::FrozenStockPoolIntent {
pool_id:program.pool_id.clone(),
signal_date: ctx.decision_date,
frozen_equity,
selection,
members,
rule,
constraints,
invest_ratio_bps,
reserve_cash,
out_of_pool_policy: program.out_of_pool_policy,
generation,
}),
}],
diagnostics,
risk_decisions,
..Default::default()
})
}
}
+224 -30
View File
@@ -17,6 +17,12 @@ use crate::{
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyRuntimeSpec {
#[serde(default,alias="stock_pool")]
pub stock_pool:Option<crate::stock_pool_execution::StockPoolProgram>,
#[serde(default)]
pub signal_book: Option<crate::signal_contract::SignalBook>,
#[serde(default)]
pub signal_book_ref: Option<crate::signal_contract::SignalBookReference>,
#[serde(default, alias = "strategy_id")]
pub strategy_id: Option<String>,
#[serde(default)]
@@ -85,6 +91,8 @@ pub struct StrategyRebalanceSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExecutionSpec {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)]
pub frequency: Option<String>,
#[serde(default, alias = "matching_type")]
@@ -158,9 +166,22 @@ pub struct StrategyExecutionSpec {
pub sell_then_buy_delay_slippage_rate: Option<f64>,
}
impl StrategyRuntimeSpec {
pub fn volume_capacity_mode(&self) -> Result<crate::execution_capacity::VolumeCapacityMode, String> {
let engine = self.engine_config.as_ref().and_then(|config| config.volume_capacity_mode);
let execution = self.execution.as_ref().and_then(|config| config.volume_capacity_mode);
if engine.zip(execution).is_some_and(|(a, b)| a != b) {
return Err("conflicting engine/execution volumeCapacityMode".into());
}
Ok(execution.or(engine).unwrap_or_default())
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyEngineConfig {
#[serde(default, alias = "volume_capacity_mode")]
pub volume_capacity_mode: Option<crate::execution_capacity::VolumeCapacityMode>,
#[serde(default)]
pub frequency: Option<String>,
#[serde(default, alias = "template_id")]
@@ -644,7 +665,7 @@ fn normalize_risk_policy_aliases_in_value(value: &mut Value) -> Result<(), Strin
/// contract can legitimately arrive with both spellings. Canonicalise those
/// pairs once at the boundary, while rejecting conflicting values instead of
/// silently choosing one.
fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
pub fn normalize_strategy_aliases_in_value(value: &mut Value) -> Result<(), String> {
normalize_strategy_aliases_in_value_inner(value, false)
}
@@ -660,7 +681,7 @@ fn normalize_strategy_aliases_in_value_inner(
for (key, child) in object.iter_mut() {
normalize_strategy_aliases_in_value_inner(
child,
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy"),
in_risk_policy || matches!(key.as_str(), "riskPolicy" | "risk_policy" | "automaticTradeProtection" | "automatic_trade_protection"),
)?;
}
}
@@ -675,11 +696,14 @@ fn normalize_strategy_aliases_in_value_inner(
}
const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
("signalBook", &["signal_book"]),
("signalBookRef", &["signal_book_ref"]),
("strategyId", &["strategy_id"]),
("tradeTimes", &["trade_times"]),
("signalSymbol", &["signal_symbol"]),
("engineConfig", &["engine_config"]),
("runtimeExpressions", &["runtime_expressions"]),
("automaticTradeProtection", &["automatic_trade_protection"]),
("rebalanceSchedule", &["rebalance_schedule"]),
("skipWindows", &["skip_windows"]),
("dynamicRange", &["dynamic_range"]),
@@ -714,10 +738,8 @@ const STRATEGY_ALIAS_GROUPS: &[(&str, &[&str])] = &[
),
("stampTaxRateAfterChange", &["stamp_tax_rate_after_change"]),
("stampTaxChangeDate", &["stamp_tax_change_date"]),
("volumeLimit", &["volume_limit"]),
("volumeLimitEnabled", &["volume_limit_enabled"]),
("liquidityLimit", &["liquidity_limit"]),
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
("volumeLimit", &["volume_limit", "volumeLimitEnabled", "volume_limit_enabled"]),
("liquidityLimit", &["liquidity_limit", "liquidityLimitEnabled", "liquidity_limit_enabled"]),
("volumePercent", &["volume_percent"]),
("riskPolicy", &["risk_policy"]),
("strictValueBudget", &["strict_value_budget"]),
@@ -741,6 +763,16 @@ fn strategy_alias_values_semantically_equal(left: &Value, right: &Value) -> bool
return true;
}
match (left, right) {
(Value::Number(left), Value::Number(right)) => {
const MAX_EXACT: i64 = 9_007_199_254_740_992;
let exact_integer = |value: &serde_json::Number| {
value.as_i64().filter(|v| (-MAX_EXACT..=MAX_EXACT).contains(v)).map(|v| v as f64)
.or_else(|| value.as_u64().filter(|v| *v <= MAX_EXACT as u64).map(|v| v as f64))
};
if left.is_f64() && !right.is_f64() { exact_integer(right).zip(left.as_f64()).is_some_and(|(a,b)| a==b) }
else if right.is_f64() && !left.is_f64() { exact_integer(left).zip(right.as_f64()).is_some_and(|(a,b)| a==b) }
else { false }
}
(Value::String(left), Value::String(right)) => left.trim() == right.trim(),
(Value::String(left), Value::Number(right))
| (Value::Number(right), Value::String(left)) => left
@@ -897,6 +929,10 @@ pub struct StrategyExpressionSelectionConfig {
pub current_day_precomputed_factors: Option<bool>,
#[serde(default, alias = "candidate_symbols_by_date")]
pub candidate_symbols_by_date: BTreeMap<String, Vec<String>>,
#[serde(default, alias = "preserve_candidate_order")]
pub preserve_candidate_order: bool,
#[serde(default, alias = "candidate_source_book")]
pub candidate_source_book: Option<crate::stock_pool_candidates::CandidateSourceBook>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -913,6 +949,8 @@ pub struct StrategyExpressionRiskConfig {
pub exposure_expr: Option<String>,
#[serde(default, alias = "position_exposure_schedule")]
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
#[serde(default, alias = "position_exposure_events")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)]
@@ -990,6 +1028,8 @@ pub struct StrategyExpressionOrderingConfig {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExpressionTradingConfig {
#[serde(default, alias = "automatic_trade_protection")]
pub automatic_trade_protection: Option<crate::holding_policy::AutomaticTradeProtection>,
#[serde(default, alias = "buy_filter_expr")]
pub buy_filter_expr: Option<String>,
#[serde(default)]
@@ -1511,7 +1551,6 @@ fn normalize_slippage_model_name(value: &str) -> String {
| "price_rate"
| "price_ratio_slippage"
| "priceratioslippage" => "price_ratio".to_string(),
"dynamic_volume_volatility" => "dynamic".to_string(),
other => other.to_string(),
}
}
@@ -1556,11 +1595,13 @@ fn parse_slippage_model(
impact_coefficient: Option<f64>,
volatility_coefficient: Option<f64>,
max_value: Option<f64>,
) -> Option<SlippageModel> {
let value = valid_non_negative(value);
let impact_coefficient = valid_non_negative(impact_coefficient);
let volatility_coefficient = valid_non_negative(volatility_coefficient);
let max_value = valid_non_negative(max_value);
) -> Result<SlippageModel, String> {
for (name, parameter) in [("slippageValue", value), ("slippageImpactCoefficient", impact_coefficient),
("slippageVolatilityCoefficient", volatility_coefficient), ("slippageMaxValue", max_value)] {
if parameter.is_some_and(|number| !number.is_finite() || number < 0.0) {
return Err(format!("{name} must be finite and non-negative"));
}
}
let model = model
.map(normalize_slippage_model_name)
.filter(|item| !item.is_empty())
@@ -1573,16 +1614,23 @@ fn parse_slippage_model(
});
match model.as_str() {
"none" => Some(SlippageModel::None),
"price_ratio" => Some(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Some(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Some(SlippageModel::LimitPrice),
"dynamic" => Some(SlippageModel::Dynamic(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5),
volatility_coefficient.unwrap_or(0.3),
max_value.or(value).unwrap_or(0.01),
))),
_ => None,
"none" => Ok(SlippageModel::None),
"price_ratio" => Ok(SlippageModel::PriceRatio(value.unwrap_or(0.0))),
"tick_size" => Ok(SlippageModel::TickSize(value.unwrap_or(0.0))),
"limit_price" => Ok(SlippageModel::LimitPrice),
"historical_volume_volatility" => {
let max_ratio = max_value.or(value).unwrap_or(0.01);
if max_ratio >= 1.0 {
return Err("historical slippage maximum must be less than 1".into());
}
Ok(SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(
impact_coefficient.unwrap_or(0.5), volatility_coefficient.unwrap_or(0.3), max_ratio,
)))
},
"dynamic" | "dynamic_volume_volatility" => Err(
"retired_slippage_model: dynamic used unfinished daily data; explicitly select historical_volume_volatility or another supported model".into()
),
_ => Err(format!("unsupported slippageModel: {model}")),
}
}
@@ -1613,20 +1661,19 @@ fn apply_execution_behavior_overrides(
|| slippage_volatility_coefficient.is_some()
|| slippage_max_value.is_some()
{
if let Some(parsed) = parse_slippage_model(
cfg.slippage_model = parse_slippage_model(
slippage_model,
slippage_value,
slippage_impact_coefficient,
slippage_volatility_coefficient,
slippage_max_value,
) {
cfg.slippage_model = parsed;
}
)?;
}
if strict_value_budget == Some(false) {
return Err("strictValueBudget=false is not supported".to_string());
}
cfg.strict_value_budget = true;
if let Some(rate) = sell_then_buy_delay_slippage_rate {
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
return Err(
@@ -1792,6 +1839,7 @@ pub fn platform_expr_config_from_spec(
strategy_spec: Option<&StrategyRuntimeSpec>,
) -> Result<PlatformExprStrategyConfig, String> {
let mut cfg = PlatformExprStrategyConfig::generic();
cfg.volume_capacity_mode = strategy_spec.map(StrategyRuntimeSpec::volume_capacity_mode).transpose()?.unwrap_or_default();
cfg.strategy_name = strategy_id.to_string();
if !signal_symbol.trim().is_empty() {
cfg.signal_symbol = signal_symbol.trim().to_string();
@@ -2110,12 +2158,29 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = selection.current_day_precomputed_factors {
cfg.current_day_precomputed_factors = enabled;
}
if selection.preserve_candidate_order && selection.candidate_symbols_by_date.is_empty() {
return Err("preserveCandidateOrder requires a dated candidate book".to_string());
}
if let Some(book) = &selection.candidate_source_book {
if !selection.preserve_candidate_order {
return Err("candidateSourceBook requires preserveCandidateOrder=true".into());
}
let expected = book.resolved_symbols()?.into_iter()
.map(|(date, symbols)| (date.to_string(), symbols)).collect::<BTreeMap<_, _>>();
if expected != selection.candidate_symbols_by_date {
return Err("candidateSourceBook differs from resolved candidateSymbolsByDate".into());
}
if cfg.selection_limit_expr.trim() == "pool_candidate_count" {
cfg.max_positions = expected.values().map(Vec::len).max().unwrap_or(0).max(1);
}
}
for (raw_date, raw_symbols) in &selection.candidate_symbols_by_date {
let trade_date = NaiveDate::parse_from_str(raw_date, "%Y-%m-%d").map_err(|_| {
format!("candidateSymbolsByDate contains invalid date: {raw_date}")
})?;
let mut symbols = BTreeSet::new();
for raw_symbol in raw_symbols {
let mut order = BTreeMap::new();
for (index, raw_symbol) in raw_symbols.iter().enumerate() {
let symbol = normalize_symbol(raw_symbol, None);
let valid = symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6
@@ -2132,8 +2197,12 @@ pub fn platform_expr_config_from_spec(
"candidateSymbolsByDate contains duplicate date/symbol: {raw_date} {symbol}"
));
}
order.insert(symbol, index);
}
cfg.candidate_symbols_by_date.insert(trade_date, symbols);
if selection.preserve_candidate_order {
cfg.candidate_order_by_date.insert(trade_date, order);
}
}
}
if let Some(allocation) = runtime_expr.allocation.as_ref()
@@ -2161,6 +2230,7 @@ pub fn platform_expr_config_from_spec(
expr.clone()
};
}
cfg.position_exposure_timeline = crate::position_exposure::PositionExposureTimeline::from_events(&risk.position_exposure_events)?;
for point in &risk.position_exposure_schedule {
let effective_date = NaiveDate::parse_from_str(
point.effective_date.trim(),
@@ -2272,6 +2342,7 @@ pub fn platform_expr_config_from_spec(
));
}
cfg.position_target_rules.push(PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: when_expr.to_string(),
remaining_position_bps: rule.remaining_position_bps,
reason: rule
@@ -2316,6 +2387,10 @@ pub fn platform_expr_config_from_spec(
}
}
if let Some(trading) = runtime_expr.trading.as_ref() {
if let Some(policy) = &trading.automatic_trade_protection {
policy.validate()?;
cfg.automatic_trade_protection = policy.clone();
}
if let Some(expr) = trading.buy_filter_expr.as_ref() {
cfg.buy_filter_expr = expr.clone();
}
@@ -2599,6 +2674,62 @@ pub fn platform_expr_config_from_spec(
}
cfg.strict_value_budget = true;
if spec.runtime_expressions.as_ref().and_then(|runtime| runtime.selection.as_ref())
.is_some_and(|selection| selection.candidate_source_book.is_some())
&& (cfg.matching_type != MatchingType::NextBarOpen || !cfg.current_day_precomputed_factors) {
return Err("daily candidate source book requires completed signal-day factors and next_bar_open".into());
}
let signal_book = match (&spec.signal_book,&spec.signal_book_ref) {
(Some(_),Some(_)) => return Err("inline_and_registered_signal_book_are_mutually_exclusive".into()),
(Some(raw),None) => Some(std::sync::Arc::new(raw.clone().validate()?)),
(None,Some(reference)) => crate::signal_contract::cached_signal_book(reference)?,
(None,None) => None,
};
if let Some(book) = signal_book {
if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) {
return Err("signal_book_requires_one_consume_signal_action".into());
}
if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() {
return Err("signal_book_schedule_does_not_match_strategy".into());
}
cfg.rotation_enabled = false;
cfg.signal_rebalance_dates = book.decision_dates();
cfg.initial_subscriptions.extend(book.symbols());
cfg.signal_book = Some(book);
} else if spec.signal_book_ref.is_some() {
if cfg.explicit_actions.len()!=1 || !matches!(cfg.explicit_actions[0],PlatformTradeAction::ConsumeSignal) {
return Err("signal_book_requires_one_consume_signal_action".into());
}
cfg.rotation_enabled=false;
} else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) {
return Err("consume_signal_requires_verified_signal_book".into());
}
let has_automatic_policy = spec.runtime_expressions.as_ref().and_then(|runtime| runtime.trading.as_ref()).is_some_and(|trading| trading.automatic_trade_protection.is_some());
if has_automatic_policy {
let limit = i64::from(cfg.automatic_trade_protection.max_holding_days);
if cfg.max_holding_days.is_some_and(|previous| previous != limit) {
return Err("conflicting maximum holding policies".into());
}
cfg.max_holding_days = (limit > 0).then_some(limit);
}
if let Some(pool)=&spec.stock_pool {
if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())}
let legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty();
if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); }
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
pool.validate(secondary_buy,false)?;
cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule {
when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps,
reason: signal.reason.clone(), stock_pool_role: signal.role,
}));
cfg.stock_pool=Some(pool.clone());
cfg.hold_until_exit_enabled=false;
cfg.daily_top_up_enabled=false;
cfg.daily_position_target_adjust_enabled=false;
cfg.target_portfolio_daily_enabled=false;
}
Ok(cfg)
}
@@ -2751,6 +2882,7 @@ fn parse_platform_trade_action(
None => None,
};
match kind.as_str() {
"consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal),
"target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart {
target_weights_expr: action
.target_weights_expr
@@ -3149,6 +3281,16 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
mod tests {
use super::*;
#[test]
fn numeric_strategy_aliases_accept_exact_zero_but_never_hide_rounding_or_conflicts() {
let cfg = platform_expr_config_from_value("fees", "000001.SZ", &serde_json::json!({
"execution":{"minimumCommission":0.0,"minimum_commission":0}
})).unwrap();
assert_eq!(cfg.minimum_commission, Some(0.0));
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(9007199254740992u64), &serde_json::json!(9007199254740993u64)));
assert!(!strategy_alias_values_semantically_equal(&serde_json::json!(0.0), &serde_json::json!(1)));
}
#[test]
fn parses_buy_filter_as_a_separate_trading_condition() {
let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({
@@ -3263,6 +3405,25 @@ mod tests {
);
}
#[test]
fn frozen_candidate_order_is_explicit_and_preserves_source_positions() {
let mut spec = serde_json::json!({"runtimeExpressions": {"selection": {
"candidateSymbolsByDate": {
"2025-01-02": ["600000.SH", "000001.SZ"], "2025-01-03": []
}
}}});
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let legacy = platform_expr_config_from_value("", "", &spec).unwrap();
assert!(legacy.candidate_order_by_date.is_empty());
spec["runtimeExpressions"]["selection"]["preserveCandidateOrder"] = serde_json::json!(true);
let ordered = platform_expr_config_from_value("", "", &spec).unwrap();
assert_eq!(ordered.candidate_order_by_date[&date]["600000.SH"], 0);
assert_eq!(ordered.candidate_order_by_date[&date]["000001.SZ"], 1);
assert!(ordered.candidate_order_by_date[&NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()].is_empty());
spec["runtimeExpressions"]["selection"]["candidateSymbolsByDate"] = serde_json::json!({});
assert!(platform_expr_config_from_value("", "", &spec).unwrap_err().to_string().contains("dated candidate book"));
}
#[test]
fn rejects_invalid_or_duplicate_static_universe_symbols() {
let invalid = serde_json::json!({
@@ -3304,6 +3465,7 @@ mod tests {
assert_eq!(
cfg.position_target_rules,
vec![PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
remaining_position_bps: 5000,
reason: "factor_reduce_position".to_string(),
@@ -4066,6 +4228,28 @@ mod tests {
);
}
#[test]
fn execution_limit_aliases_normalize_to_one_serde_field_without_touching_policy() {
for section in ["execution", "engineConfig"] {
let mut spec = serde_json::json!({});
spec[section] = serde_json::json!({
"volumeLimit": false, "volumeLimitEnabled": false, "volume_limit_enabled": false,
"liquidityLimit": true, "liquidityLimitEnabled": true, "liquidity_limit_enabled": true,
"riskPolicy": {"volumeLimitEnabled": false, "liquidityLimitEnabled": true}
});
let cfg = platform_expr_config_from_value("test", "000300.SH", &spec).unwrap();
assert!(!cfg.risk_config.trading_constraints.volume_limit_enabled);
assert!(cfg.risk_config.trading_constraints.liquidity_limit_enabled);
super::normalize_strategy_aliases_in_value(&mut spec).unwrap();
assert!(spec[section].get("volumeLimitEnabled").is_none());
assert!(spec[section].get("liquidity_limit_enabled").is_none());
assert_eq!(spec[section]["riskPolicy"]["liquidityLimitEnabled"], true);
spec[section]["liquidity_limit_enabled"] = serde_json::json!(false);
assert!(platform_expr_config_from_value("test", "000300.SH", &spec)
.unwrap_err().to_string().contains("conflicting alias values"));
}
}
#[test]
fn canonicalizes_duplicate_execution_aliases_without_changing_strategy_intent() {
let spec = serde_json::json!({
@@ -4249,10 +4433,10 @@ mod tests {
}
#[test]
fn parses_dynamic_slippage_into_platform_config() {
fn parses_explicit_historical_slippage_into_platform_config() {
let spec = serde_json::json!({
"execution": {
"slippageModel": "dynamic",
"slippageModel": "historical_volume_volatility",
"slippageImpactCoefficient": 0.6,
"slippageVolatilityCoefficient": 0.2,
"slippageMaxValue": 0.015
@@ -4263,10 +4447,20 @@ mod tests {
assert_eq!(
cfg.slippage_model,
SlippageModel::Dynamic(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
SlippageModel::HistoricalVolumeVolatility(DynamicSlippageConfig::new(0.6, 0.2, 0.015))
);
}
#[test]
fn retired_or_unknown_slippage_models_do_not_fall_back_to_fixed_or_none() {
for model in ["dynamic", "dynamic_volume_volatility", "dynamic-volume-volatility", "unknown"] {
let spec = serde_json::json!({"execution": {"slippageModel": model, "slippageValue": 0.002}});
assert!(platform_expr_config_from_value("", "", &spec).is_err(), "{model}");
}
let spec = serde_json::json!({"execution": {"slippageModel": "historical_volume_volatility", "slippageImpactCoefficient": -1}});
assert!(platform_expr_config_from_value("", "", &spec).is_err());
}
#[test]
fn engine_stock_ma_filter_generates_price_and_volume_expr() {
let spec = serde_json::json!({
+79 -21
View File
@@ -60,6 +60,8 @@ pub struct PositionLot {
pub struct Position {
pub symbol: String,
pub quantity: u32,
opened_date: Option<NaiveDate>,
last_buy_date: Option<NaiveDate>,
// ALV-compatible moving average execution price; partial sells do not rebase it.
pub average_price: f64,
// ALV-compatible moving average including buy costs; partial sells do not rebase it.
@@ -88,6 +90,8 @@ impl Position {
Self {
symbol: symbol.into(),
quantity: 0,
opened_date: None,
last_buy_date: None,
average_price: 0.0,
average_cost: 0.0,
last_price: 0.0,
@@ -114,6 +118,12 @@ impl Position {
self.quantity == 0
}
pub fn opened_date(&self) -> Option<NaiveDate> {
self.opened_date
}
pub fn last_buy_date(&self) -> Option<NaiveDate> { self.last_buy_date }
pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) {
self.buy_with_mark_price(date, quantity, price, price);
}
@@ -130,6 +140,10 @@ impl Position {
}
let previous_quantity = self.quantity;
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
if previous_quantity == 0 {
self.opened_date = Some(date);
}
let previous_average_price = self.average_price;
let previous_average_cost = self.average_cost;
let gross_amount = fixed_money_or_panic(
@@ -267,6 +281,7 @@ impl Position {
.checked_add(total_proceeds)
.ok_or_else(|| "fixed-point day sell value overflow".to_string())?;
if self.quantity == 0 {
self.opened_date = None;
self.average_price = 0.0;
self.recalculate_average_cost();
} else {
@@ -661,6 +676,7 @@ pub struct PortfolioState {
cash_receivables: Vec<CashReceivable>,
pending_cash_flows: Vec<PendingCashFlow>,
day_sold_symbols: BTreeSet<String>,
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
}
#[derive(Debug, Clone)]
@@ -697,6 +713,7 @@ impl PortfolioState {
cash_receivables: Vec::new(),
pending_cash_flows: Vec::new(),
day_sold_symbols: BTreeSet::new(),
stock_pool_states: std::collections::BTreeMap::new(),
}
}
@@ -706,6 +723,25 @@ impl PortfolioState {
self.initial_cash.to_f64()
}
pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{
self.stock_pool_states.get(pool_id).cloned().unwrap_or_default()
}
pub(crate) fn set_stock_pool_execution_state(&mut self,pool_id:&str,state:crate::stock_pool_state::StockPoolExecutionState)->Result<(),String>{
if pool_id.trim().is_empty(){return Err("stock_pool_state_identity_missing".into())}
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
}
pub(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> {
let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string())
.map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?;
let adjusted = self.stock_pool_states.iter()
.map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?)))
.collect::<Result<BTreeMap<_, _>, String>>()?;
self.stock_pool_states = adjusted;
Ok(())
}
pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64()
}
@@ -1047,8 +1083,6 @@ impl PortfolioState {
let unresolved_delisting = current_market_missing
&& data.instrument(&position.symbol).is_some_and(|instrument| {
instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none())
});
if unresolved_delisting {
position.last_price = 0.0;
@@ -1068,11 +1102,13 @@ impl PortfolioState {
position.refresh_day_pnl();
continue;
}
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
let price = data
.price(date, &position.symbol, field)
.or_else(|| data.price_on_or_before(date, &position.symbol, field))
.or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
.or_else(|| {
(position.last_price.is_finite() && position.last_price > 0.0)
(confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
.then_some(position.last_price)
})
.ok_or_else(|| DataSetError::MissingSnapshot {
@@ -1224,6 +1260,8 @@ impl PortfolioState {
}
let old_quantity = old_position.quantity;
let old_opened_date = old_position.opened_date;
let old_last_buy_date = old_position.last_buy_date;
let last_price = old_position.last_price;
let old_average_price = old_position.average_price;
let old_average_cost = old_position.average_cost;
@@ -1263,6 +1301,14 @@ impl PortfolioState {
.entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity;
successor.opened_date = match (successor.opened_date, old_opened_date) {
(Some(current), Some(previous)) => Some(current.min(previous)),
(current, previous) => current.or(previous),
};
successor.last_buy_date = match (successor.last_buy_date, old_last_buy_date) {
(Some(current), Some(previous)) => Some(current.max(previous)),
(current, previous) => current.or(previous),
};
let successor_average_price_before = successor.average_price;
let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots);
@@ -1392,8 +1438,6 @@ mod tests {
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
use std::collections::BTreeMap;
#[test]
fn cash_ledger_accumulates_micro_yuan_exactly() {
let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -1595,7 +1639,7 @@ mod tests {
vec![
DailyMarketSnapshot {
date: prev_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: None,
day_open: 10.0,
open: 10.0,
@@ -1618,7 +1662,7 @@ mod tests {
},
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: None,
day_open: 10.5,
open: 10.5,
@@ -1642,18 +1686,18 @@ mod tests {
],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -1708,7 +1752,7 @@ mod tests {
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: None,
day_open: 10.5,
open: 10.5,
@@ -1731,18 +1775,18 @@ mod tests {
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -1774,7 +1818,7 @@ mod tests {
}
#[test]
fn portfolio_carries_last_price_when_position_market_row_is_missing() {
fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
@@ -1794,7 +1838,7 @@ mod tests {
}],
vec![DailyMarketSnapshot {
date: prev_date,
symbol: "601028.SH".to_string(),
symbol: "601028.SH".into(),
timestamp: None,
day_open: 10.2,
open: 10.2,
@@ -1832,9 +1876,23 @@ mod tests {
.update_prices(prev_date, &dataset, PriceField::Close)
.expect("previous close");
portfolio.begin_trading_day();
portfolio
let error = portfolio
.update_prices(missing_date, &dataset, PriceField::Close)
.expect("missing current row should carry previous close");
.expect_err("unclassified missing current price must not be filled from history");
assert!(error.to_string().contains("601028.SH"));
let paused_dataset = DataSet::from_components(
vec![dataset.instrument("601028.SH").unwrap().clone()],
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
Vec::new(),
vec![crate::data::CandidateEligibility {
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
is_kcb: false, is_one_yuan: false, risk_level_code: None,
}],
vec![dataset.benchmark(prev_date).unwrap().clone()],
).unwrap();
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
let position = portfolio.position("601028.SH").expect("position");
assert!((position.last_price - 10.3).abs() < 1e-6);
@@ -1863,7 +1921,7 @@ mod tests {
vec![
DailyMarketSnapshot {
date: buy_date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: None,
day_open: 2.99,
open: 2.99,
@@ -1886,7 +1944,7 @@ mod tests {
},
DailyMarketSnapshot {
date: next_date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: None,
day_open: 3.03,
open: 3.03,
+428
View File
@@ -0,0 +1,428 @@
//! Dated manual adjustments are ordered facts; restoring is not a 100% target.
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum PositionExposureAction {
Scale {
#[serde(rename = "requestedBps", alias = "requested_bps")]
requested_bps: i32,
},
Set {
#[serde(rename = "targetExposureBps", alias = "target_exposure_bps")]
target_exposure_bps: i32,
},
Restore,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PositionExposureEvent {
#[serde(alias = "event_id")]
pub event_id: String,
pub sequence: u64,
#[serde(alias = "effective_at")]
pub effective_at: DateTime<Utc>,
#[serde(flatten)]
pub action: PositionExposureAction,
}
#[derive(Debug, Clone, Default)]
pub struct PositionExposureTimeline {
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>,
}
impl PositionExposureTimeline {
pub fn from_events(events: &[PositionExposureEvent]) -> Result<Self, String> {
let mut result = Self::default();
let mut ids = BTreeSet::new();
let mut sequences = BTreeSet::new();
for event in events {
if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) {
return Err("position exposure event id is missing or duplicated".into());
}
if event.sequence == 0 || !sequences.insert(event.sequence) {
return Err("position exposure event sequence must be positive and unique".into());
}
if let PositionExposureAction::Scale { requested_bps } = event.action
&& !(0..=10000).contains(&requested_bps)
{
return Err("position exposure scale must be between 0 and 10000 bps".into());
}
if let PositionExposureAction::Set {
target_exposure_bps,
} = event.action
&& !(0..=10_000).contains(&target_exposure_bps)
{
return Err("position exposure target must be between 0 and 10000 bps".into());
}
result
.events
.insert((event.effective_at, event.sequence), event.action.clone());
}
Ok(result)
}
/// Legacy day-level contracts remain day-level; never invent intraday times.
pub fn exposure_at(
&self,
at: DateTime<Utc>,
execution_date: NaiveDate,
legacy: &BTreeMap<NaiveDate, f64>,
strategy_exposure: f64,
) -> Option<f64> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, action)| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => {
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
}
Some(PositionExposureAction::Set {
target_exposure_bps,
}) => Some(f64::from(*target_exposure_bps) / 10_000.),
Some(PositionExposureAction::Restore) => None,
None => legacy
.range(..=execution_date)
.next_back()
.map(|(_, value)| *value),
}
}
pub fn scale_at(&self, at: DateTime<Utc>) -> Option<i32> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, action)| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
_ => None,
}
}
}
/// Scale new buys and desired targets without weakening sell/reduction or
/// cancellation instructions. Prices, subscriptions and cash flows are intact.
pub fn scale_explicit_intent(
mut intent: crate::OrderIntent,
bps: i32,
open_orders: &[crate::OpenOrderView],
) -> Result<Option<crate::OrderIntent>, String> {
use crate::OrderIntent as I;
if !(0..=10000).contains(&bps) {
return Err("position scale out of range".into());
}
if bps == 10000 {
return Ok(Some(intent));
}
if let I::WithTimeInForce {
intent: inner,
time_in_force,
} = intent
{
return Ok(
scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce {
intent: Box::new(intent),
time_in_force,
}),
);
}
let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32;
let amount = |value: f64, target: bool| -> Result<f64, String> {
if !value.is_finite() || (target && value < 0.) {
return Err("position override received an invalid original amount".into());
}
Ok(if value > 0. {
value * f64::from(bps) / 10000.
} else {
value
})
};
match &mut intent {
I::Shares { quantity, .. }
| I::LimitShares { quantity, .. }
| I::Lots { lots: quantity, .. }
| I::LimitLots { lots: quantity, .. } => {
if *quantity > 0 {
*quantity = integer(*quantity);
if *quantity == 0 {
return Ok(None);
}
}
}
I::TargetShares {
target_quantity, ..
}
| I::LimitTargetShares {
target_quantity, ..
} => {
if *target_quantity < 0 {
return Err("position override received a negative target quantity".into());
}
*target_quantity = integer(*target_quantity);
}
I::Value { value, .. }
| I::LimitValue { value, .. }
| I::AlgoValue { value, .. }
| I::Percent { percent: value, .. }
| I::LimitPercent { percent: value, .. }
| I::AlgoPercent { percent: value, .. } => {
*value = amount(*value, false)?;
if *value == 0. {
return Ok(None);
}
}
I::TargetValue { target_value, .. }
| I::LimitTargetValue { target_value, .. }
| I::TimedTargetValue { target_value, .. }
| I::TargetPercent {
target_percent: target_value,
..
}
| I::LimitTargetPercent {
target_percent: target_value,
..
} => {
*target_value = amount(*target_value, true)?;
}
I::TargetPortfolioSmart { target_weights, .. } => {
for value in target_weights.values_mut() {
*value = amount(*value, true)?;
}
}
I::ModifyOrder {
order_id,
new_total_quantity: Some(quantity),
..
} => {
let order = open_orders
.iter()
.find(|order| order.order_id == *order_id)
.ok_or("position override cannot resolve the order being modified")?;
if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity {
let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000;
*quantity = order.requested_quantity + extra as u32;
}
}
I::Futures { .. } | I::StockPool { .. } => {
return Err("manual equity scaling cannot transform this intent kind".into());
}
I::ModifyOrder { .. }
| I::CancelOrder { .. }
| I::CancelSymbol { .. }
| I::CancelAll { .. }
| I::UpdateUniverse { .. }
| I::Subscribe { .. }
| I::Unsubscribe { .. }
| I::DepositWithdraw { .. }
| I::FinanceRepay { .. }
| I::SetManagementFeeRate { .. } => {}
I::WithTimeInForce { .. } => unreachable!("wrapper handled first"),
}
Ok(Some(intent))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() {
let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
let event = PositionExposureEvent {
event_id: "scale".into(),
sequence: 1,
effective_at: at,
action: PositionExposureAction::Scale {
requested_bps: 5000,
},
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.),
Some(0.)
);
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2),
Some(0.1)
);
let restored = PositionExposureEvent {
event_id: "restore".into(),
sequence: 2,
effective_at: at,
action: PositionExposureAction::Restore,
};
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
assert_eq!(
timeline
.exposure_at(
at,
at.date_naive(),
&BTreeMap::from([(at.date_naive(), 1.)]),
0.2
)
.unwrap_or(0.2),
0.2
);
}
#[test]
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
use crate::OrderIntent as I;
let symbol = "000001.SZ".to_string();
let reason = "fixture".to_string();
for bps in [0, 3000, 5000, 10000] {
let ratio = f64::from(bps) / 10000.;
let buy = I::LimitShares {
symbol: symbol.clone(),
quantity: 1000,
limit_price: 12.345,
reason: reason.clone(),
};
let scaled = scale_explicit_intent(buy, bps, &[]).unwrap();
if bps == 0 {
assert!(scaled.is_none())
} else if let Some(I::LimitShares {
quantity,
limit_price,
..
}) = scaled
{
assert_eq!(quantity, (1000. * ratio) as i32);
assert_eq!(limit_price, 12.345);
} else {
panic!("wrong intent")
}
let sell = I::Shares {
symbol: symbol.clone(),
quantity: -1000,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(sell, bps, &[]).unwrap(),
Some(I::Shares {
quantity: -1000,
..
})
));
let clear = I::TargetShares {
symbol: symbol.clone(),
target_quantity: 0,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(clear, bps, &[]).unwrap(),
Some(I::TargetShares {
target_quantity: 0,
..
})
));
let target = I::TargetPercent {
symbol: symbol.clone(),
target_percent: 0.2,
reason: reason.clone(),
};
if let Some(I::TargetPercent { target_percent, .. }) =
scale_explicit_intent(target, bps, &[]).unwrap()
{
assert!((target_percent - 0.2 * ratio).abs() < 1e-12)
} else {
panic!("wrong target")
}
let deposit = I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(deposit, bps, &[]).unwrap(),
Some(I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
..
})
));
}
assert!(
scale_explicit_intent(
I::TargetValue {
symbol,
target_value: f64::NAN,
reason
},
0,
&[]
)
.is_err()
);
}
#[test]
fn same_day_adjustments_restore_and_future_events_keep_their_own_times() {
let events: Vec<PositionExposureEvent> = serde_json::from_value(json!([
{"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0},
{"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000},
{"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"},
{"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000}
])).unwrap();
let timeline = PositionExposureTimeline::from_events(&events).unwrap();
let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]);
for (time, expected) in [
("09:30:00", Some(0.8)),
("10:00:00", Some(0.)),
("12:59:59", Some(0.)),
("13:00:00", Some(0.5)),
("14:00:00", None),
("15:00:00", None),
] {
let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00"))
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(at, date, &legacy, 0.2),
expected,
"{time}"
);
}
let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2),
None
);
}
#[test]
fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() {
let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000});
for (key, value) in [
("action", json!("other")),
("effectiveAt", json!("2026-09-10 09:30:00")),
("targetExposureBps", json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(serde_json::from_value::<PositionExposureEvent>(invalid).is_err());
}
let event: PositionExposureEvent = serde_json::from_value(valid).unwrap();
assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err());
let mut invalid = event.clone();
invalid.action = PositionExposureAction::Set {
target_exposure_bps: 10001,
};
assert!(PositionExposureTimeline::from_events(&[invalid]).is_err());
let mut duplicate = event.clone();
duplicate.event_id = "two".into();
assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err());
}
}
+185 -41
View File
@@ -76,6 +76,26 @@ impl Default for StaticRiskRuleConfig {
}
}
impl StaticRiskRuleConfig {
pub(crate) fn selection_checks_enabled(&self) -> bool {
(self.blacklist_enabled && !self.blacklisted_symbols.is_empty())
|| self.selection_state_checks_enabled()
}
fn selection_state_checks_enabled(&self) -> bool {
self.reject_st_selection
|| self.reject_star_st_selection
|| self.reject_paused_selection
|| self.reject_inactive_selection
|| self.reject_new_listing_selection
|| self.reject_kcb_selection
|| self.reject_bjse_selection
|| self.reject_one_yuan_selection
|| self.reject_upper_limit_selection
|| self.reject_lower_limit_selection
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TradingConstraintConfig {
/// Shared execution limits. These fields intentionally use the same
@@ -138,6 +158,16 @@ pub struct FidcRiskDecisionAudit {
}
impl FidcRiskDecisionAudit {
pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self {
Self {
date, symbol: symbol.into(), scope: RiskCheckScope::Buy,
stage: "buy_planning".into(), accepted: false,
rule_code: reason.into(), reason: reason.into(),
config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(),
selection_batch_id: None, order_id: None,
}
}
pub fn rejected_selection(
date: NaiveDate,
symbol: impl Into<String>,
@@ -208,14 +238,8 @@ impl ChinaAShareRiskControl {
{
return Some("inactive_or_delisted");
}
let status = instrument.status.trim().to_ascii_lowercase();
let terminal_status = matches!(
status.as_str(),
"inactive" | "delisted" | "terminated" | "expired"
);
if terminal_status && instrument.delisted_at.is_none() {
return Some("inactive_or_delisted");
}
// Latest reference status has no historical as-of date. Execution-day
// risk snapshots remain authoritative; missing quotes are not waived.
None
}
@@ -410,10 +434,11 @@ impl ChinaAShareRiskControl {
}
let reject_one_yuan = match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
};
if reject_one_yuan
&& !instrument.is_some_and(Instrument::is_exchange_traded_fund)
&& (candidate.is_one_yuan
|| (market.day_open.is_finite() && market.day_open > 0.0 && market.day_open <= 1.0))
{
@@ -483,6 +508,15 @@ impl ChinaAShareRiskControl {
) {
return Some(reason);
}
if !check_price.is_finite() || check_price <= 0.0 {
return Some("invalid execution price");
}
// Daily candidate flags can describe the later close. Execution
// price constraints must use this order's actual pricing clock.
if config.static_rules.reject_one_yuan_buy && check_price <= 1.0
&& !instrument.is_some_and(Instrument::is_exchange_traded_fund) {
return Some("one_yuan");
}
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
return Some("buy_disabled");
}
@@ -640,16 +674,7 @@ fn missing_risk_state_fields(code: &str) -> Vec<String> {
fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool {
let fields = missing_risk_state_fields(code);
if fields.is_empty() {
return config.static_rules.reject_st_selection
|| config.static_rules.reject_star_st_selection
|| config.static_rules.reject_paused_selection
|| config.static_rules.reject_inactive_selection
|| config.static_rules.reject_new_listing_selection
|| config.static_rules.reject_kcb_selection
|| config.static_rules.reject_bjse_selection
|| config.static_rules.reject_one_yuan_selection
|| config.static_rules.reject_upper_limit_selection
|| config.static_rules.reject_lower_limit_selection;
return config.static_rules.selection_state_checks_enabled();
}
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
}
@@ -664,7 +689,6 @@ fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell;
}
@@ -741,7 +765,7 @@ fn missing_single_field_rejected(
},
"is_one_yuan" | "one_yuan" => match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
},
"allow_buy" => match scope {
@@ -765,18 +789,7 @@ fn missing_single_field_rejected(
RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell,
},
_ => match scope {
RiskCheckScope::Selection => {
config.static_rules.reject_st_selection
|| config.static_rules.reject_star_st_selection
|| config.static_rules.reject_paused_selection
|| config.static_rules.reject_inactive_selection
|| config.static_rules.reject_new_listing_selection
|| config.static_rules.reject_kcb_selection
|| config.static_rules.reject_bjse_selection
|| config.static_rules.reject_one_yuan_selection
|| config.static_rules.reject_upper_limit_selection
|| config.static_rules.reject_lower_limit_selection
}
RiskCheckScope::Selection => config.static_rules.selection_state_checks_enabled(),
RiskCheckScope::Buy => {
config.static_rules.reject_st_buy
|| config.static_rules.reject_star_st_buy
@@ -785,7 +798,6 @@ fn missing_single_field_rejected(
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell
}
@@ -843,7 +855,7 @@ mod tests {
Some(&instrument("delisted", None)),
date,
),
Some("inactive_or_delisted")
None
);
assert_eq!(
ChinaAShareRiskControl::instrument_rejection_reason(
@@ -857,7 +869,7 @@ mod tests {
fn candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: "002633.SZ".to_string(),
symbol: "002633.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -873,7 +885,7 @@ mod tests {
fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: "002633.SZ".to_string(),
symbol: "002633.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: last_price,
open: last_price,
@@ -902,6 +914,138 @@ mod tests {
position
}
#[test]
fn selection_check_activation_covers_every_configured_flag_and_blacklist_state() {
let fields = [
"reject_st_selection", "reject_star_st_selection", "reject_paused_selection",
"reject_inactive_selection", "reject_new_listing_selection", "reject_kcb_selection",
"reject_bjse_selection", "reject_one_yuan_selection", "reject_upper_limit_selection",
"reject_lower_limit_selection",
];
let base = serde_json::to_value(StaticRiskRuleConfig::default()).unwrap();
let declared = base.as_object().unwrap().keys()
.filter(|key| key.ends_with("_selection"))
.map(String::as_str).collect::<BTreeSet<_>>();
assert_eq!(declared, fields.into_iter().collect());
for mask in 0..(1_u32 << fields.len()) {
for (blacklist_enabled, populated) in [(false, false), (false, true), (true, false), (true, true)] {
let mut value = base.clone();
for (bit, field) in fields.iter().enumerate() {
value[*field] = serde_json::json!(mask & (1 << bit) != 0);
}
value["blacklist_enabled"] = serde_json::json!(blacklist_enabled);
value["blacklisted_symbols"] = if populated {
serde_json::json!(["002633.SZ"])
} else { serde_json::json!([]) };
let config: StaticRiskRuleConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.selection_checks_enabled(), mask != 0 || (blacklist_enabled && populated));
}
}
}
#[test]
fn inactive_selection_checks_preserve_missing_facts_and_execution_rejections() {
let date = d(2025, 2, 6);
let mut candidate = candidate(date);
candidate.is_st = true;
candidate.is_star_st = true;
candidate.is_paused = true;
candidate.is_new_listing = true;
candidate.is_kcb = true;
candidate.is_one_yuan = true;
candidate.allow_buy = false;
let snapshot = market(date, 0.9, 0.9);
let config = FidcRiskControlConfig::default();
assert!(!config.static_rules.selection_checks_enabled());
let instrument = instrument("delisted", Some(date));
for code in [None, Some("not_listed"), Some("inactive_or_delisted"),
Some("missing_risk_state"), Some("missing_risk_state:is_st;is_kcb|allow_buy"),
Some("missing_risk_state:unknown_fact"), Some("missing_risk_state:IS_PAUSED")] {
candidate.risk_level_code = code.map(str::to_owned);
assert_eq!(ChinaAShareRiskControl::selection_rejection_decision_with_config(
date, &candidate, &snapshot, Some(&instrument), &config), None);
}
candidate.risk_level_code = None;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
date, &candidate, &snapshot, None, 0.9, &config), Some("paused"));
assert_eq!(ChinaAShareRiskControl::sell_rejection_reason_with_config(
date, &candidate, &snapshot, None, None, 0.9, &config), Some("paused"));
let mut blacklist_only = config;
blacklist_only.static_rules.blacklisted_symbols.insert(candidate.symbol.to_string());
assert!(blacklist_only.static_rules.selection_checks_enabled());
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &snapshot, None, &blacklist_only), Some("blacklisted"));
}
#[test]
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let mut snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.is_one_yuan = true;
snapshot.day_open = 0.9;
snapshot.close = 0.8;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.is_one_yuan = false;
snapshot.day_open = 1.2;
snapshot.close = 1.3;
for price in [0.9, 1.0] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("one_yuan"));
}
let mut relaxed = config;
relaxed.static_rules.reject_one_yuan_buy = false;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
}
#[test]
fn fund_identity_excludes_stock_one_yuan_rule_but_not_actual_price_and_pause_checks() {
let day=d(2025,2,6);
let mut candidate=candidate(day);
let mut snapshot=market(day,1.2,0.5);
snapshot.lower_limit=0.01;snapshot.upper_limit=10.;
let instrument=Instrument{symbol:candidate.symbol.to_string(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
let config=FidcRiskControlConfig::default();
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None);
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price"));
candidate.is_paused=true;snapshot.paused=true;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),Some("paused"));
}
#[test]
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.risk_level_code = Some("missing_risk_state:is_one_yuan".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.risk_level_code = Some("missing_risk_state:is_st".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), Some("missing_risk_state"));
candidate.risk_level_code = None;
for price in [0.0, f64::NAN, f64::INFINITY] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("invalid execution price"));
}
}
#[test]
fn explicit_one_yuan_selection_policy_still_uses_selection_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
candidate.is_one_yuan = true;
let snapshot = market(day, 1.2, 0.5);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_one_yuan_selection = true;
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
day, &candidate, &snapshot, None, &config), Some("one_yuan"));
}
#[test]
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
let prev_date = d(2024, 4, 16);
@@ -966,7 +1110,7 @@ mod tests {
config
.static_rules
.blacklisted_symbols
.insert(candidate.symbol.clone());
.insert(candidate.symbol.to_string());
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
@@ -1011,7 +1155,7 @@ mod tests {
let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string();
candidate.symbol = symbol.into();
for is_kcb in [false, true] {
candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
@@ -1119,7 +1263,7 @@ mod tests {
fn configurable_bjse_filter_can_be_disabled() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.symbol = "430047.BJ".to_string();
candidate.symbol = "430047.BJ".into();
candidate.allow_sell = true;
let market = market(date, 6.27, 5.63);
let default_selection =
@@ -1151,7 +1295,7 @@ mod tests {
fn concrete_kcb_reason_wins_over_generic_missing_risk_state() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string();
candidate.symbol = "688506.SH".into();
candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63);
+590
View File
@@ -0,0 +1,590 @@
//! Immutable, account-independent trading signals. Quantity and execution
//! prices are intentionally absent; the existing broker owns those decisions.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::strategy::{OrderIntent, StrategyContext};
use crate::portfolio::PortfolioState;
pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalBookReference {
pub book_id: String,
pub version_sha256: String,
pub artifact_sha256: String,
}
impl SignalBookReference {
pub fn validate(&self) -> Result<(), String> {
if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256)
|| self.book_id != format!("signal_book_{}",self.version_sha256)
{ return Err("signal_book_reference_invalid".into()); }
Ok(())
}
}
#[derive(Default)]
struct SignalCache {
entries: BTreeMap<String,Weak<ValidatedSignalBook>>,
retained: std::collections::VecDeque<(String,Arc<ValidatedSignalBook>,usize)>,
}
fn signal_cache() -> &'static Mutex<SignalCache> {
static CACHE: OnceLock<Mutex<SignalCache>> = OnceLock::new();
CACHE.get_or_init(||Mutex::new(SignalCache::default()))
}
pub fn cached_signal_book(reference: &SignalBookReference) -> Result<Option<Arc<ValidatedSignalBook>>,String> {
reference.validate()?;
let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade);
if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) {
return Err("signal_book_cached_version_mismatch".into());
}
Ok(book)
}
pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result<Arc<ValidatedSignalBook>,String> {
use sha2::{Digest,Sha256};
reference.validate()?;
if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 {
return Err("signal_book_artifact_hash_or_size_invalid".into());
}
let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?;
if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); }
let book=Arc::new(raw.validate()?);
let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?;
cache.entries.retain(|_,value|value.strong_count()>0);
if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); }
cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book));
let estimated=body.len().saturating_mul(4);
if estimated<=128*1024*1024 {
cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated));
while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::<usize>()>128*1024*1024 {
cache.retained.pop_front();
}
}
Ok(book)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalProvenance {
Observed,
Reconstructed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalFrequency {
Daily,
Minute,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum SignalAction {
TargetWeight { symbol: String, weight: f64 },
BuyCondition { symbol: String, allowed: bool },
Exit { symbol: String },
Reduce { symbol: String, remaining_ratio: f64 },
}
impl SignalAction {
fn symbol(&self) -> &str {
match self {
Self::TargetWeight { symbol, .. }
| Self::BuyCondition { symbol, .. }
| Self::Exit { symbol }
| Self::Reduce { symbol, .. } => symbol,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalSnapshot {
pub signal_at: DateTime<Utc>,
pub decision_at: DateTime<Utc>,
pub input_as_of: DateTime<Utc>,
pub input_available_at: DateTime<Utc>,
pub generated_at: DateTime<Utc>,
pub published_at: DateTime<Utc>,
pub input_sha256: String,
pub complete_targets: bool,
pub actions: Vec<SignalAction>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignalBook {
pub schema: String,
pub version_sha256: String,
pub generator_sha256: String,
pub model_sha256: Option<String>,
pub knowledge_cutoff: Option<DateTime<Utc>>,
pub provenance: SignalProvenance,
pub frequency: SignalFrequency,
pub expected_decisions: Vec<DateTime<Utc>>,
pub snapshots: Vec<SignalSnapshot>,
}
#[derive(Debug, Clone)]
pub struct ValidatedSignalBook {
book: SignalBook,
index: BTreeMap<NaiveDateTime, usize>,
}
fn valid_sha(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn shanghai(value: DateTime<Utc>) -> NaiveDateTime {
value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local()
}
impl SignalBook {
pub fn content_sha256(&self) -> Result<String, String> {
let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?;
value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256");
value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null);
value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::<Vec<_>>());
for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) {
let object=raw.as_object_mut().ok_or("signal_snapshot_required")?;
object.remove("generatedAt");
object.remove("publishedAt");
for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at),
("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] {
object.insert(key.into(),serde_json::json!(at.timestamp_micros()));
}
for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) {
match action {
SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())),
SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())),
_=>{}
}
}
}
fn sorted(value:serde_json::Value)->serde_json::Value {
match value {
serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value)))
.collect::<BTreeMap<_,_>>().into_iter().collect()),
serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()),
other=>other,
}
}
let raw=serde_json::to_vec(&sorted(value)).map_err(|error|error.to_string())?;
Ok(format!("{:x}",Sha256::digest(raw)))
}
pub fn validate(self) -> Result<ValidatedSignalBook, String> {
if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256)
|| !valid_sha(&self.generator_sha256)
{
return Err("signal_book_identity_invalid".into());
}
if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value))
|| self.model_sha256.is_some() != self.knowledge_cutoff.is_some()
{ return Err("signal_model_training_identity_incomplete".into()); }
if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000
|| self.expected_decisions.len() != self.snapshots.len()
{
return Err("signal_book_decision_coverage_incomplete".into());
}
let mut index = BTreeMap::new();
let mut previous = None;
let mut total_actions = 0usize;
for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() {
if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at]
.iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) {
return Err("signal_timestamp_requires_microsecond_precision".into());
}
if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) {
return Err("signal_book_decisions_duplicate_or_unordered".into());
}
previous = Some(*expected);
if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected
|| snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at
|| snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256)
|| snapshot.generated_at < snapshot.input_available_at
|| self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff)
{
return Err("signal_book_future_or_invalid_input".into());
}
if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected {
return Err("observed_signal_not_available_at_decision".into());
}
total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?;
if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); }
let mut action_keys = BTreeSet::new();
let mut target_symbols = BTreeSet::new();
let mut reductions = BTreeSet::new();
let mut total_weight = 0.0;
for action in &snapshot.actions {
let symbol = action.symbol();
if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); }
let kind = match action {
SignalAction::TargetWeight { weight, .. } => {
if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); }
target_symbols.insert(symbol);
total_weight += weight;
"target"
}
SignalAction::BuyCondition { .. } => "buy_condition",
SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" }
SignalAction::Reduce { remaining_ratio, .. } => {
if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); }
reductions.insert(symbol);
"reduce"
}
};
if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); }
}
if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); }
if snapshot.complete_targets && !reductions.is_empty() {
return Err("complete_target_snapshot_cannot_mix_relative_exits".into());
}
if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); }
for symbol in &reductions {
if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) {
return Err("signal_exit_reduction_conflict".into());
}
}
index.insert(shanghai(*expected), number);
}
if self.content_sha256()? != self.version_sha256 {
return Err("signal_book_content_hash_mismatch".into());
}
Ok(ValidatedSignalBook { book: self, index })
}
}
impl ValidatedSignalBook {
pub fn require_observed(&self) -> Result<(), String> {
if self.book.provenance != SignalProvenance::Observed {
return Err("reconstructed_signal_forbidden_in_online_execution".into());
}
Ok(())
}
pub fn version_sha256(&self) -> &str { &self.book.version_sha256 }
pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 }
pub fn decision_dates(&self) -> BTreeSet<NaiveDate> {
self.index.keys().map(|value| value.date()).collect()
}
pub fn symbols(&self) -> BTreeSet<String> {
self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions)
.map(|action| action.symbol().to_owned()).collect()
}
pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> {
let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?;
if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() {
return Err("observed_signal_consumption_clock_missing".into());
}
let consumption_clock=ctx.current_datetime()
.unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session"));
let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily;
if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date {
return Err("next_open_signal_contains_execution_session_inputs".into());
}
if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock {
return Err("signal_not_available_at_consumption_clock".into());
}
if self.book.provenance == SignalProvenance::Observed
&& (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) {
return Err("observed_signal_published_after_consumption_clock".into());
}
Ok(snapshot)
}
pub fn is_due_on(&self, execution_date: NaiveDate) -> bool {
self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..)
.next().is_some_and(|(at,_)|at.date()==execution_date)
}
fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option<NaiveTime>, lagged: bool) -> Result<&SignalSnapshot, String> {
let at = if self.book.frequency == SignalFrequency::Daily && lagged {
execution_date.and_hms_opt(9, 30, 0).expect("next open")
} else {
execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close")))
};
self.index.get(&at).map(|index| &self.book.snapshots[*index])
.ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}"))
}
pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result<Vec<OrderIntent>, String> {
let snapshot = self.snapshot_for(ctx)?;
self.snapshot_intents(snapshot, ctx.portfolio)
}
fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result<Vec<OrderIntent>, String> {
let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at);
let mut intents = Vec::new();
let mut weights = BTreeMap::new();
for action in &snapshot.actions {
match action {
SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => {
weights.insert(symbol.clone(), *weight);
}
SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent {
symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(),
}),
SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent {
symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(),
}),
SignalAction::Reduce { symbol, remaining_ratio } => {
if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) {
let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32;
let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?;
intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() });
}
}
SignalAction::BuyCondition { .. } => {}
}
}
if snapshot.complete_targets {
if weights.is_empty() {
for position in portfolio.positions().values().filter(|position| position.quantity > 0) {
intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() });
}
} else {
intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights,
order_prices: None, valuation_prices: None, reason });
}
}
Ok(intents)
}
pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result<BTreeMap<String, String>, String> {
Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action {
SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())),
_ => None,
}).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use serde_json::json;
fn book() -> SignalBook {
let decision: DateTime<Utc> = "2025-01-07T09:30:00+08:00".parse().unwrap();
let source: DateTime<Utc> = "2025-01-06T15:00:00+08:00".parse().unwrap();
seal(SignalBook {
schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64),
model_sha256: Some("d".repeat(64)),
knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()),
provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily,
expected_decisions: vec![decision], snapshots: vec![SignalSnapshot {
signal_at: source,
decision_at: decision, input_as_of: source, input_available_at: source,
generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10),
input_sha256: "c".repeat(64), complete_targets: true,
actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }],
}],
})
}
fn seal(mut book:SignalBook)->SignalBook {
book.version_sha256=book.content_sha256().unwrap();
book
}
fn at_context<T>(at: Option<NaiveDateTime>, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T {
let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![crate::BenchmarkSnapshot {
date:NaiveDate::from_ymd_opt(2025,1,6).unwrap(), benchmark:"clock-fixture".into(),
open:100.0, close:100.0, prev_close:100.0, volume:1,
}]).unwrap();
let portfolio = PortfolioState::new(10_000.0);
let symbols = BTreeSet::new();
action(&StrategyContext {
execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(),
decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0,
data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[],
dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None,
active_datetime:at, order_events:&[], fills:&[],
})
}
#[test]
fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() {
let mut raw = book();
raw.provenance=SignalProvenance::Observed;
raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap();
raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap();
let value=seal(raw).validate().unwrap();
for clock in ["2025-01-06T15:00:00", "2025-01-07T08:45:00"] {
at_context(Some(clock.parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock");
assert!(ctx.portfolio.positions().is_empty());
});
}
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap().len(),1);
assert!(ctx.portfolio.positions().is_empty());
});
at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing"));
}
#[test]
fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() {
let value=book().validate().unwrap();
at_context(Some("2025-01-06T15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok()));
at_context(Some("2025-01-06T14:59:59".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock");
});
let mut raw=book();
raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap();
raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of;
raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of;
let value=seal(raw).validate().unwrap();
at_context(Some("2025-01-07T09:30:00".parse().unwrap()), |ctx| {
assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs");
});
}
#[test]
fn historical_reconstruction_is_not_online_publication() {
let validated = book().validate().unwrap();
assert!(validated.require_observed().unwrap_err().contains("reconstructed"));
let mut observed = book();
observed.provenance = SignalProvenance::Observed;
assert!(observed.clone().validate().unwrap_err().contains("not_available"));
observed.snapshots[0].generated_at = observed.snapshots[0].decision_at;
observed.snapshots[0].published_at = observed.snapshots[0].decision_at;
seal(observed).validate().unwrap().require_observed().unwrap();
}
#[test]
fn rejects_future_inputs_and_model_knowledge() {
for field in 0..3 {
let mut value = book();
let future = value.snapshots[0].decision_at + Duration::seconds(1);
match field {
0 => value.snapshots[0].input_as_of = future,
1 => value.snapshots[0].input_available_at = future,
_ => value.knowledge_cutoff = Some(future),
}
assert!(value.validate().unwrap_err().contains("future"));
}
}
#[test]
fn rejects_quantities_prices_and_unknown_signal_fields() {
for name in ["quantity", "execution_price", "account_id", "cash"] {
let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5});
action[name] = json!(100);
assert!(serde_json::from_value::<SignalAction>(action).is_err());
}
}
#[test]
fn coverage_and_duplicate_actions_fail_closed() {
let mut value = book();
value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1));
assert!(value.validate().unwrap_err().contains("coverage"));
let mut value = book();
value.snapshots.push(value.snapshots[0].clone());
value.expected_decisions.push(value.expected_decisions[0]);
assert!(value.validate().unwrap_err().contains("duplicate"));
let mut value = book();
let repeated = value.snapshots[0].actions[0].clone();
value.snapshots[0].actions.push(repeated);
assert!(value.validate().unwrap_err().contains("duplicate"));
}
#[test]
fn rejects_overallocation_nonfinite_and_ambiguous_actions() {
for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
let mut value = book();
value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight };
assert!(value.validate().is_err());
}
let mut value = book();
value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 });
assert!(value.validate().unwrap_err().contains("exposure"));
let mut value = book();
value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()});
assert!(value.validate().is_err());
}
#[test]
fn next_open_uses_decision_session_and_never_nearest_signal() {
let value = book().validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap();
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok());
assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err());
assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err());
}
#[test]
fn reduction_is_resolved_from_each_accounts_actual_position() {
let mut raw = book();
raw.snapshots[0].complete_targets = false;
raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}];
let value = seal(raw).validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
for (held, expected) in [(1000,500),(3000,1500)] {
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut("000001.SZ").buy(day,held,10.0);
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected));
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held);
}
assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty());
}
#[test]
fn empty_complete_snapshot_clears_only_that_accounts_holdings() {
let mut raw = book();
raw.snapshots[0].actions.clear();
let value = seal(raw).validate().unwrap();
let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap();
let mut portfolio = PortfolioState::new(100_000.0);
portfolio.position_mut("000002.SZ").buy(day,200,10.0);
let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap();
assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0));
}
#[test]
fn platform_spec_consumes_book_without_running_another_selection() {
let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}});
let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap();
assert!(!config.rotation_enabled && config.signal_book.is_some());
assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal]));
}
#[test]
fn changed_valid_contents_must_not_reuse_a_version_hash() {
let mut raw=book();
raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}];
assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch");
seal(raw).validate().unwrap();
}
#[test]
fn completed_daily_inputs_may_be_published_after_market_close() {
let mut raw=book();
raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()];
raw.snapshots[0].decision_at=raw.expected_decisions[0];
raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap();
raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap();
raw.snapshots[0].signal_at=raw.snapshots[0].input_available_at;
raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at;
raw.snapshots[0].published_at=raw.snapshots[0].generated_at;
raw.provenance=SignalProvenance::Observed;
seal(raw).validate().unwrap().require_observed().unwrap();
}
}
@@ -0,0 +1,229 @@
//! Candidate provenance and ordering; contains no market-data or broker I/O.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
pub const CANDIDATE_SOURCES_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateSourceMode {
Manual,
FilteredManual,
Automatic,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CandidateSourcePriority {
#[default]
ManualFirst,
AutomaticFirst,
ListOrder,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateSourcePolicy {
pub schema_version: u32,
pub mode: CandidateSourceMode,
#[serde(default)]
pub priority: CandidateSourcePriority,
#[serde(default)]
pub merged_order: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CandidateMember {
pub symbol: String,
pub manual: bool,
pub automatic: bool,
}
fn symbols(values: &[String], label: &str) -> Result<Vec<String>, String> {
let mut seen = BTreeSet::new();
values.iter().map(|value| {
let symbol = value.trim().to_ascii_uppercase();
if !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6 && code.bytes().all(|byte| byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "BJ")
}) {
return Err(format!("{label}: invalid qualified security code {value}"));
}
if !seen.insert(symbol.clone()) {
return Err(format!("{label}: duplicate security {symbol}"));
}
Ok(symbol)
}).collect()
}
impl CandidateSourcePolicy {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != CANDIDATE_SOURCES_SCHEMA {
return Err("candidate_sources schema_version must be 1".into());
}
symbols(&self.merged_order, "candidate_sources.merged_order")?;
if self.mode != CandidateSourceMode::Mixed && self.priority != CandidateSourcePriority::ManualFirst {
return Err("candidate source priority only applies to mixed sources".into());
}
if self.priority != CandidateSourcePriority::ListOrder && !self.merged_order.is_empty() {
return Err("merged_order requires list_order priority".into());
}
Ok(())
}
pub fn uses_screen(&self) -> bool {
self.mode != CandidateSourceMode::Manual
}
pub fn validate_screen_binding(&self, manual: &[String], has_screen: bool) -> Result<(), String> {
self.validate()?;
symbols(manual, "manual candidates")?;
if self.uses_screen() != has_screen {
return Err("candidate source mode and screen contract must agree".into());
}
if self.mode == CandidateSourceMode::FilteredManual && manual.is_empty() {
return Err("filtered_manual requires manual members; an empty scope must not become all-market".into());
}
Ok(())
}
}
/// Overlap between two valid sources denotes one member with both provenance
/// flags. Duplicates *within* a source are invalid evidence, not fixed by dedup.
pub fn resolve_candidates(
policy: &CandidateSourcePolicy,
manual: &[String],
automatic: Option<&[String]>,
) -> Result<Vec<CandidateMember>, String> {
policy.validate_screen_binding(manual, automatic.is_some())?;
let manual = symbols(manual, "manual candidates")?;
let automatic = automatic.map(|values| symbols(values, "automatic candidates")).transpose()?.unwrap_or_default();
let manual_set = manual.iter().cloned().collect::<BTreeSet<_>>();
let auto_set = automatic.iter().cloned().collect::<BTreeSet<_>>();
if policy.mode == CandidateSourceMode::FilteredManual && !auto_set.is_subset(&manual_set) {
return Err("filtered_manual snapshot contains a security outside the manual scope".into());
}
let mut ordered = match policy.mode {
CandidateSourceMode::Manual => manual.clone(),
CandidateSourceMode::FilteredManual | CandidateSourceMode::Automatic => automatic.clone(),
CandidateSourceMode::Mixed => {
let (first, second) = if policy.priority == CandidateSourcePriority::AutomaticFirst {
(&automatic, &manual)
} else { (&manual, &automatic) };
let mut union = first.clone();
let mut seen = first.iter().cloned().collect::<BTreeSet<_>>();
union.extend(second.iter().filter(|symbol| seen.insert((*symbol).clone())).cloned());
union
}
};
if policy.priority == CandidateSourcePriority::ListOrder {
let present = ordered.iter().cloned().collect::<BTreeSet<_>>();
let prefix = symbols(&policy.merged_order, "candidate_sources.merged_order")?
.into_iter().filter(|symbol| present.contains(symbol)).collect::<Vec<_>>();
let selected = prefix.iter().cloned().collect::<BTreeSet<_>>();
let tail = ordered.into_iter().filter(|symbol| !selected.contains(symbol));
ordered = prefix.into_iter().chain(tail).collect();
}
Ok(ordered.into_iter().map(|symbol| CandidateMember {
manual: manual_set.contains(&symbol), automatic: auto_set.contains(&symbol), symbol,
}).collect())
}
/// Raw daily automatic candidates remain unchanged. Every resolved list is
/// derived by the shared kernel; absent dates never inherit yesterday's list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateSourceBook {
pub schema_version: u32,
pub policy: CandidateSourcePolicy,
pub manual_symbols: Vec<String>,
pub automatic_symbols_by_date: BTreeMap<NaiveDate, Vec<String>>,
pub source_snapshot_sha256: String,
pub source_coverage_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_symbols: Option<Vec<String>>,
}
impl CandidateSourceBook {
pub fn resolve(&self) -> Result<BTreeMap<NaiveDate, Vec<CandidateMember>>, String> {
if self.schema_version != CANDIDATE_SOURCES_SCHEMA || !self.policy.uses_screen() {
return Err("candidate source book requires schema 1 and a screened source".into());
}
for value in [&self.source_snapshot_sha256, &self.source_coverage_sha256] {
if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("candidate source book requires snapshot and coverage SHA256".into());
}
}
if self.automatic_symbols_by_date.is_empty() {
return Err("candidate source book requires explicit covered trading dates".into());
}
let scope = self.execution_symbols.as_ref().map(|values| symbols(values, "candidate execution scope")
.map(|values| values.into_iter().collect::<BTreeSet<_>>())).transpose()?;
self.automatic_symbols_by_date.iter().map(|(day, values)| {
resolve_candidates(&self.policy, &self.manual_symbols, Some(values))
.map(|members| (*day, members.into_iter().filter(|member| scope.as_ref().is_none_or(|scope| scope.contains(&member.symbol))).collect()))
}).collect()
}
pub fn resolved_symbols(&self) -> Result<BTreeMap<NaiveDate, Vec<String>>, String> {
Ok(self.resolve()?.into_iter().map(|(date, values)|
(date, values.into_iter().map(|member| member.symbol).collect())).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list(values: &[&str]) -> Vec<String> { values.iter().map(|value| value.to_string()).collect() }
fn policy(mode: CandidateSourceMode, priority: CandidateSourcePriority) -> CandidateSourcePolicy {
CandidateSourcePolicy { schema_version: 1, mode, priority, merged_order: vec![] }
}
#[test]
fn mixed_sources_preserve_priority_and_both_provenances() {
let manual = list(&["600000.SH", "000001.SZ"]);
let automatic = list(&["000002.SZ", "000001.SZ"]);
for (priority, expected) in [
(CandidateSourcePriority::ManualFirst, list(&["600000.SH", "000001.SZ", "000002.SZ"])),
(CandidateSourcePriority::AutomaticFirst, list(&["000002.SZ", "000001.SZ", "600000.SH"])),
] {
let result = resolve_candidates(&policy(CandidateSourceMode::Mixed, priority), &manual, Some(&automatic)).unwrap();
assert_eq!(result.iter().map(|value| value.symbol.clone()).collect::<Vec<_>>(), expected);
let overlap = result.iter().find(|value| value.symbol == "000001.SZ").unwrap();
assert!(overlap.manual && overlap.automatic);
}
}
#[test]
fn list_order_reuses_explicit_prefix_and_appends_new_candidates() {
let mut p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ListOrder);
p.merged_order = list(&["000002.SZ", "600036.SH", "600000.SH"]);
let result = resolve_candidates(&p, &list(&["600000.SH", "000001.SZ"]), Some(&list(&["000002.SZ", "000003.SZ"]))).unwrap();
assert_eq!(result.into_iter().map(|row| row.symbol).collect::<Vec<_>>(), list(&["000002.SZ", "600000.SH", "000001.SZ", "000003.SZ"]));
}
#[test]
fn missing_snapshot_duplicate_input_and_empty_filtered_scope_fail() {
let p = policy(CandidateSourceMode::Mixed, CandidateSourcePriority::ManualFirst);
assert!(resolve_candidates(&p, &[], None).is_err());
assert!(resolve_candidates(&p, &[], Some(&list(&["000001.SZ", "000001.sz"]))).is_err());
let p = policy(CandidateSourceMode::FilteredManual, CandidateSourcePriority::ManualFirst);
assert!(resolve_candidates(&p, &[], Some(&[])).unwrap_err().contains("all-market"));
assert!(resolve_candidates(&p, &list(&["000001.SZ"]), Some(&list(&["600000.SH"]))).is_err());
}
#[test]
fn zero_automatic_day_keeps_manual_members_without_inheriting_old_auto_targets() {
let day1 = NaiveDate::from_ymd_opt(2026, 9, 9).unwrap();
let day2 = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let book = CandidateSourceBook { schema_version: 1,
policy: policy(CandidateSourceMode::Mixed, CandidateSourcePriority::AutomaticFirst),
manual_symbols: list(&["510300.SH"]),
automatic_symbols_by_date: BTreeMap::from([(day1, list(&["000001.SZ"])), (day2, vec![])]),
source_snapshot_sha256: "a".repeat(64), source_coverage_sha256: "b".repeat(64), execution_symbols:None };
let result = book.resolved_symbols().unwrap();
assert_eq!(result[&day1], list(&["000001.SZ", "510300.SH"]));
assert_eq!(result[&day2], list(&["510300.SH"]));
let mut auto = book; auto.policy = policy(CandidateSourceMode::Automatic, CandidateSourcePriority::ManualFirst);
assert!(auto.resolved_symbols().unwrap()[&day2].is_empty());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
//! Dated non-tradability and valuation are separate from execution quotations.
use super::*;
pub(super) fn validate(
signal_date: NaiveDate,
constraints: &StockPoolDecisionConstraints,
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
) -> Result<(), String> {
for (symbol, fact) in &constraints.frozen_positions {
if constraints.execution_date != Some(fact.trade_date)
|| fact.trade_date < signal_date
|| fact.reason != "paused"
|| fact.valuation_price <= Decimal::ZERO
|| current.get(symbol).is_none_or(|row| row.0 <= Decimal::ZERO)
{
return Err(format!("stock_pool_frozen_position_invalid:{symbol}"));
}
}
if constraints
.prior_target_weights
.iter()
.any(|(symbol, weight)| {
normalize_stock_symbol(symbol).as_ref() != Some(symbol)
|| !(0..=10_000).contains(weight)
})
{
return Err("stock_pool_prior_target_weights_invalid".into());
}
Ok(())
}
pub(super) fn valuation(
symbol: &str,
quotes: &HashMap<String, &MarketSnapshot>,
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
) -> Result<Decimal, String> {
frozen
.get(symbol)
.map(|fact| fact.valuation_price)
.or_else(|| quotes.get(symbol).map(|quote| quote.last_price))
.filter(|price| *price > Decimal::ZERO)
.ok_or_else(|| format!("{symbol} confirmed holding valuation missing"))
}
pub(super) fn weights(
original: &[String],
active: &[String],
members: &[StockPoolMemberSpec],
explicit: &BTreeMap<String, i32>,
constraints: &StockPoolDecisionConstraints,
reserved_slots: usize,
target_count: usize,
) -> Result<BTreeMap<String, i32>, String> {
let count = original.len() + reserved_slots;
let order = members
.iter()
.map(|member| (&member.symbol, member.requested_order))
.collect::<BTreeMap<_, _>>();
let mut original_budget_symbols = original.to_vec();
for symbol in constraints.frozen_positions.keys() {
if order.contains_key(symbol) && !original_budget_symbols.contains(symbol) {
original_budget_symbols.push(symbol.clone());
}
}
if original_budget_symbols.len() != original.len() {
original_budget_symbols
.sort_by_key(|symbol| order.get(symbol).copied().unwrap_or(i32::MAX));
}
let initial = original_budget_symbols
.iter()
.enumerate()
.map(|(index, symbol)| {
let weight = if explicit.is_empty() {
if count == 0 {
0
} else {
10_000 / count as i32 + i32::from(index < 10_000 % count)
}
} else {
*explicit.get(symbol).unwrap_or(&0)
};
(symbol.clone(), weight)
})
.collect::<Vec<_>>();
let mut frozen = BTreeMap::new();
for symbol in constraints.frozen_positions.keys() {
let weight = explicit
.get(symbol)
.copied()
.or_else(|| constraints.prior_target_weights.get(symbol).copied())
.or_else(|| {
initial
.iter()
.find(|(key, _)| key == symbol)
.map(|(_, weight)| *weight)
})
.ok_or_else(|| format!("stock_pool_frozen_position_target_weight_missing:{symbol}"))?;
frozen.insert(symbol.clone(), weight);
}
let frozen_total = frozen.values().copied().sum::<i32>();
if frozen_total > 10_000 {
return Err("stock_pool_frozen_position_weights_exceed_budget".into());
}
let mut free = initial
.into_iter()
.filter(|(symbol, _)| !frozen.contains_key(symbol))
.map(|(symbol, weight)| (symbol, weight as u32))
.collect::<Vec<_>>();
let total = free.iter().map(|(_, weight)| *weight).sum::<u32>();
let available = (10_000 - frozen_total) as u32;
// A paused holding removed from today's candidates still owns its prior
// budget. Scale only the new tradable allocation, never the frozen leg.
if total > available {
let mut remainder = available;
for (_, weight) in &mut free {
*weight = (u64::from(*weight) * u64::from(available) / u64::from(total)) as u32;
remainder -= *weight;
}
for (_, weight) in free.iter_mut().take(remainder as usize) {
*weight += 1;
}
}
let excluded = free
.iter()
.filter(|(symbol, _)| !active.contains(symbol))
.map(|(symbol, _)| symbol.clone())
.collect();
let candidates = active
.iter()
.filter(|symbol| !frozen.contains_key(*symbol))
.cloned()
.collect::<Vec<_>>();
let allocated = crate::platform_expr_strategy::replenish_target_weight_bps(
&free,
&candidates,
&excluded,
target_count.saturating_sub(
frozen
.keys()
.filter(|symbol| original.contains(symbol))
.count(),
),
);
frozen.extend(
allocated
.into_iter()
.map(|(symbol, weight)| (symbol, weight as i32)),
);
Ok(frozen)
}
@@ -0,0 +1,93 @@
//! Index exposure is independent of relative-weight maintenance. Reduce the
//! existing proportions only when the portfolio exceeds its index budget.
use super::*;
pub(super) struct IndexCapTarget {
pub quantity: Decimal,
pub blocked_by_t1: bool,
}
pub(super) fn remaining_index_targets(
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
members: &HashMap<String, &StockPoolMemberSpec>,
automatic: &BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
already_planned: &[StockPoolPlanRow],
quotes: &HashMap<String, &MarketSnapshot>,
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
budget: Decimal,
) -> Result<BTreeMap<String, IndexCapTarget>, String> {
struct Entry {
symbol: String,
quantity: Decimal,
minimum: Decimal,
price: Decimal,
}
let other = already_planned
.iter()
.map(|row| (&row.symbol, row))
.collect::<BTreeMap<_, _>>();
let mut fixed = Decimal::ZERO;
let mut entries = Vec::new();
for (symbol, (quantity, closable, _)) in current.iter().filter(|(_, row)| row.0 > Decimal::ZERO)
{
let price = super::frozen::valuation(symbol, quotes, frozen)?;
let minimum = (*quantity - *closable).max(Decimal::ZERO);
let remaining = other
.get(symbol)
.map(|row| row.target_quantity.min(*quantity))
.unwrap_or(*quantity)
.max(minimum);
if frozen.contains_key(symbol)
|| automatic
.get(symbol)
.is_some_and(|permission| permission.sell_denial.is_some())
{
fixed += *quantity * price;
} else if members.contains_key(symbol) && remaining > Decimal::ZERO {
entries.push(Entry {
symbol: symbol.clone(),
quantity: remaining,
minimum,
price,
});
} else {
fixed += remaining * price;
}
}
let mut remaining = entries
.iter()
.map(|row| row.quantity * row.price)
.sum::<Decimal>();
let mut available = (budget - fixed).max(Decimal::ZERO);
if remaining <= available {
return Ok(BTreeMap::new());
}
// The highest non-sellable proportions are fixed first; the remainder
// keeps its existing relative weights. No planned sale funds a new buy.
entries.sort_by(|left, right| {
(right.minimum / right.quantity)
.cmp(&(left.minimum / left.quantity))
.then_with(|| left.symbol.cmp(&right.symbol))
});
let mut result = BTreeMap::new();
for row in entries {
let scale = if remaining > Decimal::ZERO {
(available / remaining).min(Decimal::ONE)
} else {
Decimal::ZERO
};
let desired = (row.quantity * scale).floor();
let blocked = desired < row.minimum;
let target = desired.max(row.minimum).min(row.quantity);
remaining -= row.quantity * row.price;
available = (available - target * row.price).max(Decimal::ZERO);
result.insert(
row.symbol,
IndexCapTarget {
quantity: target,
blocked_by_t1: blocked,
},
);
}
Ok(result)
}
@@ -0,0 +1,344 @@
//! Explicit index timing, shared by historical and online stock-pool planners.
//! Inputs are completed official sessions, not a shortened available-row window.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MarketTimingPolicy {
pub enabled: bool,
pub index_code: Option<String>,
pub fast_window: Option<usize>,
pub slow_window: Option<usize>,
pub volatility_window: Option<usize>,
pub drawdown_window: Option<usize>,
pub bull_exposure: Option<f64>,
pub bear_exposure: Option<f64>,
pub volatility_threshold: Option<f64>,
pub volatility_cap: Option<f64>,
pub drawdown_threshold: Option<f64>,
pub drawdown_cap: Option<f64>,
}
impl MarketTimingPolicy {
pub fn from_allocation(value: &serde_json::Value) -> Result<Self, String> {
let raw = value.get("market_timing").filter(|value| !value.is_null());
let policy = raw
.map(|raw| serde_json::from_value::<Self>(raw.clone()))
.transpose()
.map_err(|error| format!("market_timing_invalid:{error}"))?
.unwrap_or_default();
policy.validate()?;
Ok(policy)
}
pub fn validate(&self) -> Result<(), String> {
if let Some(index) = &self.index_code {
let valid = index.split_once('.').is_some_and(|(code, exchange)| {
(6..=12).contains(&code.len())
&& code
.bytes()
.all(|v| v.is_ascii_uppercase() || v.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
});
if !valid {
return Err("market_timing_index_code_invalid".into());
}
} else if self.enabled {
return Err("market_timing_index_code_required".into());
}
for (key, value) in [
("fast_window", self.fast_window),
("slow_window", self.slow_window),
("volatility_window", self.volatility_window),
("drawdown_window", self.drawdown_window),
] {
match value {
Some(value) if !(2..=250).contains(&value) => {
return Err(format!("market_timing_{key}_must_be_2_to_250"));
}
None if self.enabled => return Err(format!("market_timing_{key}_required")),
_ => {}
}
}
if let (Some(fast), Some(slow)) = (self.fast_window, self.slow_window) {
if fast >= slow {
return Err("market_timing_fast_window_must_be_less_than_slow_window".into());
}
}
for (key, value) in [
("bull_exposure", self.bull_exposure),
("bear_exposure", self.bear_exposure),
("volatility_threshold", self.volatility_threshold),
("volatility_cap", self.volatility_cap),
("drawdown_threshold", self.drawdown_threshold),
("drawdown_cap", self.drawdown_cap),
] {
match value {
Some(value) if !value.is_finite() || !(0.0..=1.0).contains(&value) => {
return Err(format!("market_timing_{key}_must_be_in_0_to_1"));
}
None if self.enabled => return Err(format!("market_timing_{key}_required")),
_ => {}
}
}
Ok(())
}
pub fn required_history(&self) -> Result<usize, String> {
self.validate()?;
if !self.enabled {
return Ok(0);
}
Ok(self
.slow_window
.unwrap()
.max(self.volatility_window.unwrap() + 1)
.max(self.drawdown_window.unwrap()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexClose {
pub date: NaiveDate,
pub close: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MarketTimingInput {
pub index_code: String,
pub as_of_date: NaiveDate,
pub official_dates: Vec<NaiveDate>,
pub closes: Vec<IndexClose>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarketTimingEvaluation {
pub index_code: String,
pub as_of_date: NaiveDate,
pub window_start: NaiveDate,
pub session_count: usize,
pub fast_ma: f64,
pub slow_ma: f64,
pub volatility: f64,
pub drawdown: f64,
pub exposure: f64,
pub reason_codes: Vec<String>,
pub policy_sha256: String,
pub input_sha256: String,
pub kernel_sha256: String,
}
pub fn implementation_sha256() -> String {
format!(
"{:x}",
Sha256::digest(include_bytes!("stock_pool_index_policy.rs"))
)
}
pub fn evaluate(
policy: &MarketTimingPolicy,
input: &MarketTimingInput,
decision_date: NaiveDate,
) -> Result<MarketTimingEvaluation, String> {
let needed = policy.required_history()?;
if needed == 0 {
return Err("market_timing_disabled_has_no_evaluation".into());
}
if policy.index_code.as_deref() != Some(input.index_code.as_str()) {
return Err("market_timing_input_index_mismatch".into());
}
if input.as_of_date > decision_date {
return Err("market_timing_future_input".into());
}
if input.official_dates.len() != needed
|| input.closes.len() != needed
|| input.official_dates.last() != Some(&input.as_of_date)
|| input
.official_dates
.windows(2)
.any(|pair| pair[0] >= pair[1])
{
return Err("market_timing_official_calendar_incomplete".into());
}
if input
.closes
.iter()
.zip(&input.official_dates)
.any(|(row, date)| row.date != *date || !row.close.is_finite() || row.close <= 0.)
{
return Err("market_timing_completed_index_rows_incomplete".into());
}
let closes = input.closes.iter().map(|row| row.close).collect::<Vec<_>>();
let mean = |window: usize| closes[needed - window..].iter().sum::<f64>() / window as f64;
let fast_ma = mean(policy.fast_window.unwrap());
let slow_ma = mean(policy.slow_window.unwrap());
let returns = closes[needed - policy.volatility_window.unwrap() - 1..]
.windows(2)
.map(|pair| pair[1] / pair[0] - 1.)
.collect::<Vec<_>>();
let average = returns.iter().sum::<f64>() / returns.len() as f64;
let volatility = (returns
.iter()
.map(|value| (value - average).powi(2))
.sum::<f64>()
/ (returns.len() - 1) as f64)
.sqrt();
let peak = closes[needed - policy.drawdown_window.unwrap()..]
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let drawdown = 1. - closes[needed - 1] / peak;
let bullish = fast_ma > slow_ma;
let mut exposure = if bullish {
policy.bull_exposure.unwrap()
} else {
policy.bear_exposure.unwrap()
};
let mut reason_codes = vec![
if bullish {
"fast_above_slow"
} else {
"fast_not_above_slow"
}
.into(),
];
if volatility >= policy.volatility_threshold.unwrap() {
exposure = exposure.min(policy.volatility_cap.unwrap());
reason_codes.push("volatility_cap".into());
}
if drawdown >= policy.drawdown_threshold.unwrap() {
exposure = exposure.min(policy.drawdown_cap.unwrap());
reason_codes.push("drawdown_cap".into());
}
if [fast_ma, slow_ma, volatility, drawdown, exposure]
.iter()
.any(|value| !value.is_finite())
{
return Err("market_timing_nonfinite_result".into());
}
let hash = |value: &serde_json::Value| -> Result<String, String> {
Ok(format!(
"{:x}",
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
))
};
Ok(MarketTimingEvaluation {
index_code: input.index_code.clone(),
as_of_date: input.as_of_date,
window_start: input.official_dates[0],
session_count: needed,
fast_ma,
slow_ma,
volatility,
drawdown,
exposure,
reason_codes,
policy_sha256: hash(&serde_json::to_value(policy).map_err(|error| error.to_string())?)?,
input_sha256: hash(&serde_json::to_value(input).map_err(|error| error.to_string())?)?,
kernel_sha256: implementation_sha256(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> MarketTimingPolicy {
serde_json::from_value(serde_json::json!({"enabled":true,"index_code":"000300.SH","fast_window":10,"slow_window":30,
"volatility_window":20,"drawdown_window":60,"bull_exposure":1.0,"bear_exposure":0.3,"volatility_threshold":0.025,
"volatility_cap":0.3,"drawdown_threshold":0.08,"drawdown_cap":0.2})).unwrap()
}
fn input(values: Vec<f64>) -> MarketTimingInput {
let dates = (0..values.len())
.map(|i| {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Duration::days(i as i64)
})
.collect::<Vec<_>>();
MarketTimingInput {
index_code: "000300.SH".into(),
as_of_date: *dates.last().unwrap(),
closes: dates
.iter()
.zip(values)
.map(|(date, close)| IndexClose { date: *date, close })
.collect(),
official_dates: dates,
}
}
#[test]
fn trend_and_caps_match_the_declared_math_and_keep_zero_meaningful() {
for (prices, expected) in [
((0..60).map(|i| 100. + i as f64).collect(), 1.),
((0..60).map(|i| 200. - i as f64).collect(), 0.2),
(
(0..59)
.map(|i| 100. + i as f64 * 0.1)
.chain([150.])
.collect(),
0.3,
),
] {
let input = input(prices);
let result = evaluate(&policy(), &input, input.as_of_date).unwrap();
assert_eq!(result.exposure, expected);
assert_eq!(result.session_count, 60);
assert_eq!(result.input_sha256.len(), 64);
}
let input = input(vec![100.; 60]);
let mut zero = policy();
zero.bear_exposure = Some(0.);
assert_eq!(
evaluate(&zero, &input, input.as_of_date).unwrap().exposure,
0.
);
}
#[test]
fn missing_or_duplicate_or_future_rows_never_shorten_the_window() {
let base = input(vec![100.; 60]);
let mut invalid = base.clone();
invalid.closes.remove(5);
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
let mut invalid = base.clone();
invalid.closes[5].date = invalid.closes[4].date;
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
let mut invalid = base.clone();
invalid.closes[5].close = f64::NAN;
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
assert!(
evaluate(
&policy(),
&base,
base.as_of_date - chrono::Duration::days(1)
)
.is_err()
);
let mut invalid = base.clone();
invalid.index_code = "000852.SH".into();
assert!(evaluate(&policy(), &invalid, base.as_of_date).is_err());
}
#[test]
fn disabled_is_optional_but_enabled_parameters_are_not_invented() {
assert_eq!(
MarketTimingPolicy::from_allocation(&serde_json::Value::Null)
.unwrap()
.required_history()
.unwrap(),
0
);
assert!(
MarketTimingPolicy::from_allocation(
&serde_json::json!({"market_timing":{"enabled":true}})
)
.is_err()
);
let mut invalid = policy();
invalid.fast_window = invalid.slow_window;
assert!(invalid.validate().is_err());
let mut invalid = policy();
invalid.volatility_window = Some(250);
assert_eq!(invalid.required_history().unwrap(), 251);
}
}
@@ -0,0 +1,179 @@
//! Shared causal indicator calculation for stock-pool screening.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::factor_events::{self, Expr, Frame};
pub fn implementation_sha256() -> String {
use sha2::{Digest,Sha256};
let mut identity=Sha256::new();
identity.update(include_bytes!("stock_pool_indicators.rs"));
identity.update(factor_events::catalog()["expression_kernel_sha256"].as_str().expect("native kernel identity"));
format!("{:x}",identity.finalize())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct IndicatorSpec {
pub indicator: String,
pub field: String,
pub window: usize,
#[serde(default)]
pub output: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InputSeries {
pub frame: Frame,
/// Source-admitted historical suspension/lifecycle gaps, not guessed from
/// missing prices. The pure endpoint never certifies those source facts.
#[serde(default)]
pub admitted_missing_dates: BTreeSet<NaiveDate>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub specs: BTreeMap<String, IndicatorSpec>,
pub series: Vec<InputSeries>,
pub output_start_date: NaiveDate,
pub output_end_date: NaiveDate,
}
#[derive(Debug, Serialize)]
pub struct OutputSeries {
pub symbol: String,
pub indices: Vec<usize>,
pub values: BTreeMap<String, Vec<Option<f64>>>,
}
pub fn evaluate(request: Request) -> Result<Vec<OutputSeries>, String> {
let row_count: usize = request.series.iter().map(|series| series.frame.timestamps.len()).sum();
if request.specs.is_empty() || request.specs.len() > 64 || row_count > 60_000
|| row_count.saturating_mul(request.specs.len()) > 1_000_000
|| request.output_start_date > request.output_end_date {
return Err("stock_pool_indicator_request_budget_or_range_invalid".into());
}
for spec in request.specs.values() {
if !matches!(spec.indicator.as_str(), "ma" | "ema" | "kdj") || !(2..=10_000).contains(&spec.window)
|| !matches!(spec.field.as_str(), "close" | "volume" | "high" | "low")
|| (spec.indicator == "kdj" && !matches!(spec.output.as_deref(), Some("k" | "d" | "j"))) {
return Err("stock_pool_indicator_spec_invalid".into());
}
}
let mut seen = BTreeSet::new();
let mut result = Vec::new();
for series in request.series {
let frame = series.frame;
frame.validate()?;
if frame.frequency != "1d" { return Err("stock_pool_indicators_require_daily_source".into()); }
if series.admitted_missing_dates.iter().any(|day| !frame.timestamps.iter().any(|stamp| stamp.date_naive() == *day)) {
return Err("admitted_missing_date_is_outside_the_input_frame".into());
}
if !seen.insert(frame.symbol.clone()) { return Err("stock_pool_indicator_duplicate_symbol".into()); }
let indices = frame.timestamps.iter().enumerate().filter(|(_, stamp)| {
let day = stamp.date_naive(); request.output_start_date <= day && day <= request.output_end_date
}).map(|(index, _)| index).collect::<Vec<_>>();
let mut values = BTreeMap::new();
for (key, spec) in &request.specs {
let fields: Vec<&str> = if spec.indicator == "kdj" { vec!["high", "low", "close"] } else { vec![&spec.field] };
for field in &fields {
let raw = frame.fields.get(*field).ok_or_else(|| format!("indicator_field_missing:{field}"))?;
if raw.len() != frame.timestamps.len() { return Err("indicator_field_length_mismatch".into()); }
}
for index in 0..frame.timestamps.len() {
let valid = fields.iter().all(|field| frame.fields[*field][index].is_some_and(f64::is_finite));
if valid {
if fields.iter().any(|field| frame.fields[*field][index].is_some_and(|value| if *field == "volume" { value < 0.0 } else { value <= 0.0 })) {
return Err(format!("indicator_input_value_invalid:{}:{}", frame.symbol, frame.timestamps[index]));
}
if spec.indicator == "kdj" {
let (hi, lo, close) = (frame.fields["high"][index].unwrap(), frame.fields["low"][index].unwrap(), frame.fields["close"][index].unwrap());
if hi < lo || close < lo || close > hi { return Err(format!("indicator_ohlc_invalid:{}:{}", frame.symbol, frame.timestamps[index])); }
}
}
else if !series.admitted_missing_dates.contains(&frame.timestamps[index].date_naive()) {
return Err(format!("unclassified_indicator_input_gap:{}:{}", frame.symbol, frame.timestamps[index]));
}
}
let native_values = {
// The native KDJ has its own initialisation and lookback. Do
// not recreate it as STOCH or an independently seeded loop.
// Preserve admitted NULL rows too: the shared kernel defines
// gap/warmup semantics; compressing the calendar changes them.
let expression: Expr = serde_json::from_value(if spec.indicator == "kdj" {
json!({"kind":"indicator","name":"KDJ","inputs":[],
"parameters":{"optInFastK_Period":spec.window,"optInSlowK_Period":3,
"optInSlowK_MAType":13,"optInSlowD_Period":3,"optInSlowD_MAType":13},
"output":match spec.output.as_deref() { Some("k") => 0, Some("d") => 1, _ => 2 }})
} else {
json!({"kind":"indicator","name":if spec.indicator == "ma" {"SMA"} else {"EMA"},
"inputs":[{"kind":"field","name":spec.field}],"parameters":{"optInTimePeriod":spec.window}})
}).map_err(|error| error.to_string())?;
factor_events::evaluate(&expression, &frame)?.values
};
values.insert(key.clone(), indices.iter().map(|index| native_values[*index]).collect());
}
result.push(OutputSeries { symbol: frame.symbol, indices, values });
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> Request {
serde_json::from_value(json!({"specs":{"ma":{"indicator":"ma","field":"close","window":3},"ema":{"indicator":"ema","field":"close","window":3},"j":{"indicator":"kdj","field":"close","window":3,"output":"j"}},
"series":[{"frame":{"symbol":"000001.SZ","frequency":"1d","decision_at":"2026-09-10T17:00:00+08:00",
"timestamps":["2026-09-07T15:00:00+08:00","2026-09-08T15:00:00+08:00","2026-09-09T15:00:00+08:00","2026-09-10T15:00:00+08:00"],
"available_at":["2026-09-07T16:00:00+08:00","2026-09-08T16:00:00+08:00","2026-09-09T16:00:00+08:00","2026-09-10T16:00:00+08:00"],
"fields":{"close":[1.,2.,3.,4.],"high":[2.,3.,4.,5.],"low":[0.5,1.,2.,3.]}}}],
"output_start_date":"2026-09-09","output_end_date":"2026-09-10"})).unwrap()
}
#[test]
fn uses_shared_ma_ema_and_preserves_recursive_prefix_before_output_window() {
let rows = evaluate(request()).unwrap();
assert_eq!(rows[0].indices,vec![2,3]);
assert_eq!(rows[0].values["ma"],vec![Some(2.),Some(3.)]);
assert_eq!(rows[0].values["ema"],vec![Some(2.),Some(3.)]);
assert_eq!(rows[0].values["j"], vec![None, None]); // KDJ has a longer native lookback.
let mut later = request(); later.output_start_date = NaiveDate::from_ymd_opt(2026,9,10).unwrap();
assert_eq!(evaluate(later).unwrap()[0].values["ema"],vec![rows[0].values["ema"][1]]);
}
#[test]
fn kdj_uses_the_same_frozen_native_kernel_as_event_conditions() {
let mut data = request();
let frame = &mut data.series[0].frame;
for i in 4..16 {
let day = NaiveDate::from_ymd_opt(2026,9,7).unwrap() + chrono::Duration::days(i);
frame.timestamps.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T15:00:00+08:00")).unwrap());
frame.available_at.push(chrono::DateTime::parse_from_rfc3339(&format!("{day}T16:00:00+08:00")).unwrap());
frame.fields.get_mut("close").unwrap().push(Some(i as f64 + 1.));
frame.fields.get_mut("high").unwrap().push(Some(i as f64 + 2.));
frame.fields.get_mut("low").unwrap().push(Some(i as f64));
}
frame.decision_at = chrono::DateTime::parse_from_rfc3339("2026-09-23T17:00:00+08:00").unwrap();
data.output_start_date=NaiveDate::from_ymd_opt(2026,9,7).unwrap();
data.output_end_date=NaiveDate::from_ymd_opt(2026,9,22).unwrap();
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"KDJ","inputs":[],"parameters":{"optInFastK_Period":3},"output":2})).unwrap();
let expected=factor_events::evaluate(&native,frame).unwrap().values;
assert!(expected.iter().any(Option::is_some));
assert_eq!(evaluate(data).unwrap()[0].values["j"],expected);
}
#[test]
fn missing_inputs_require_source_admission_and_future_inputs_fail() {
let mut data = request(); data.series[0].frame.fields.get_mut("close").unwrap()[1] = None;
assert!(evaluate(data).unwrap_err().contains("unclassified_indicator_input_gap"));
let mut data = request(); data.series[0].frame.available_at[3] = chrono::DateTime::parse_from_rfc3339("2026-09-11T16:00:00+08:00").unwrap();
assert!(evaluate(data).is_err());
let mut data=request();data.series[0].frame.fields.get_mut("close").unwrap()[1]=None;
data.series[0].admitted_missing_dates.insert(NaiveDate::from_ymd_opt(2026,9,8).unwrap());
let native:Expr=serde_json::from_value(json!({"kind":"indicator","name":"EMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}})).unwrap();
let expected=factor_events::evaluate(&native,&data.series[0].frame).unwrap().values;
assert_eq!(expected[2..], [None,None]);
assert_eq!(evaluate(data).unwrap()[0].values["ema"],expected[2..]);
}
}
@@ -0,0 +1,108 @@
//! Configurable index-to-market-cap band. Values are CNY, not implicit yi.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexMarketCapPolicy {
pub schema_version: u32,
pub index_code: String,
pub field: String,
pub value_unit: String,
pub index_low: f64,
pub index_high: f64,
pub lower_at_low: f64,
pub lower_at_high: f64,
pub upper_at_low: f64,
pub upper_at_high: f64,
}
impl IndexMarketCapPolicy {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != 1 || self.value_unit != "CNY"
|| !matches!(self.field.as_str(), "market_cap" | "float_market_cap")
{ return Err("index_market_cap_contract_invalid".into()); }
let index = self.index_code.split_once('.').is_some_and(|(code, exchange)| {
(6..=12).contains(&code.len())
&& code.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "CSI" | "CNI")
});
if !index { return Err("index_market_cap_index_invalid".into()); }
if [self.index_low,self.index_high,self.lower_at_low,self.lower_at_high,self.upper_at_low,self.upper_at_high]
.iter().any(|value| !value.is_finite() || *value <= 0.)
|| self.index_low >= self.index_high || self.lower_at_low > self.upper_at_low
|| self.lower_at_high > self.upper_at_high
{ return Err("index_market_cap_bounds_invalid".into()); }
Ok(())
}
pub fn band(&self, close: f64) -> Result<(f64, f64), String> {
self.validate()?;
if !close.is_finite() || close <= 0. { return Err("index_market_cap_close_invalid".into()); }
let t = (close.clamp(self.index_low,self.index_high) - self.index_low) / (self.index_high-self.index_low);
Ok((self.lower_at_low + t*(self.lower_at_high-self.lower_at_low),
self.upper_at_low + t*(self.upper_at_high-self.upper_at_low)))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexMarketCapRow { pub date: NaiveDate, pub close: f64 }
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub policy: IndexMarketCapPolicy,
pub official_dates: Vec<NaiveDate>,
pub index_code: String,
pub closes: Vec<IndexMarketCapRow>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Band { pub date: NaiveDate, pub index_close: f64, pub lower: f64, pub upper: f64 }
pub fn implementation_sha256() -> String { format!("{:x}", Sha256::digest(include_bytes!("stock_pool_market_cap.rs"))) }
pub fn evaluate(input: &Request) -> Result<Vec<Band>, String> {
input.policy.validate()?;
if input.index_code != input.policy.index_code || input.official_dates.is_empty()
|| input.official_dates.len() > 4000 || input.official_dates.len() != input.closes.len()
|| input.official_dates.windows(2).any(|pair| pair[0]>=pair[1])
|| input.closes.iter().zip(&input.official_dates).any(|(row, day)| row.date != *day)
{ return Err("index_market_cap_calendar_or_identity_mismatch".into()); }
input.closes.iter().map(|row| {
let (lower,upper)=input.policy.band(row.close)?;
Ok(Band{date:row.date,index_close:row.close,lower,upper})
}).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn policy()->IndexMarketCapPolicy {
serde_json::from_value(serde_json::json!({"schema_version":1,"index_code":"000300.SH","field":"market_cap","value_unit":"CNY",
"index_low":4000,"index_high":6000,"lower_at_low":2000000000_f64,"lower_at_high":3000000000_f64,
"upper_at_low":5000000000_f64,"upper_at_high":8000000000_f64})).unwrap()
}
#[test]
fn interpolates_declared_endpoints_and_clamps_without_business_defaults(){
assert_eq!(policy().band(3000.).unwrap(),(2e9,5e9));
assert_eq!(policy().band(5000.).unwrap(),(2.5e9,6.5e9));
assert_eq!(policy().band(7000.).unwrap(),(3e9,8e9));
let mut decreasing=policy();decreasing.lower_at_low=3e9;decreasing.lower_at_high=2e9;
assert_eq!(decreasing.band(5000.).unwrap(),(2.5e9,6.5e9));
assert!(policy().band(f64::NAN).is_err());
let mut invalid=policy();invalid.value_unit="亿元".into();assert!(invalid.validate().is_err());
invalid=policy();invalid.index_high=4000.;assert!(invalid.validate().is_err());
invalid=policy();invalid.lower_at_low=9e9;assert!(invalid.validate().is_err());
}
#[test]
fn missing_duplicate_or_mismatched_index_inputs_do_not_shrink_the_calendar(){
let day=NaiveDate::from_ymd_opt(2026,9,11).unwrap();
let mut input=Request{policy:policy(),official_dates:vec![day],index_code:"000300.SH".into(),closes:vec![IndexMarketCapRow{date:day,close:5000.}]};
assert_eq!(evaluate(&input).unwrap()[0].lower,2.5e9);
input.official_dates.push(day);assert!(evaluate(&input).is_err());input.official_dates.pop();
input.index_code="932000.CSI".into();assert!(evaluate(&input).is_err());
input.index_code="000300.SH".into();input.closes.clear();assert!(evaluate(&input).is_err());
}
}
@@ -0,0 +1,148 @@
//! Condition facts are distinct from the quote's per-observation fill capacity.
//! Only a complete, declared raw-minute prefix can prove a session total.
use std::collections::BTreeMap;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
use rust_decimal::Decimal;
use crate::data::IntradayExecutionQuote;
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
})
}
/// The cash-equity minute feed includes the opening observation and a separate
/// post-close segment. Trading eligibility remains owned by the dated rules.
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
let minute = time.hour() * 60 + time.minute();
let next = match minute {
570..=689 | 781..=899 | 906..=929 => minute + 1,
690 => 781,
900 => 906,
_ => return None,
};
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
}
#[derive(Debug, Default)]
pub(crate) struct SessionTotalsCache {
pub date: Option<NaiveDate>,
pub symbols: BTreeMap<String, MinutePrefix>,
}
#[derive(Debug)]
pub(crate) struct MinutePrefix {
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
failure: String,
}
impl MinutePrefix {
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
let mut values = BTreeMap::new();
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let mut volume = 0_u64;
let mut amount = Decimal::ZERO;
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
for quote in quotes {
let time = quote.timestamp.time();
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
break;
}
if time != expected {
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
break;
}
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
break;
}
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
break;
};
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
quote.amount_delta.to_string().parse::<Decimal>().ok()
} else { None };
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
break;
};
volume = next_volume;
amount = next_amount;
values.insert(time, (Decimal::from(volume), amount));
let Some(next) = next_minute(time) else { break };
expected = next;
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
}
Self { values, failure }
}
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
}
#[test]
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
rows[2].volume_delta = 999999;
rows[2].amount_delta = f64::NAN;
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
}
#[test]
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
let first = quote(9,30,100,1000.);
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
}
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
}
#[test]
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
loop {
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
let Some(next) = next_minute(time) else { break }; time=next;
}
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
}
#[test]
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
fn real_source_session_prefix_matches_observed_checkpoints() {
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
assert_eq!(rows.len(),242);
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
}
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
}
}
+345
View File
@@ -0,0 +1,345 @@
//! Durable intent progress, deliberately separate from actual-fill holding
//! protection. A published target starts no holding/protection timer.
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::stock_pool_execution::{
Position, StockPoolMemberSpec, StockPoolPlan, normalize_stock_symbol,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolEntryProgress {
pub pending: bool,
pub observed_holding: bool,
pub first_decision_date: NaiveDate,
pub latest_generation: String,
pub latest_target_value: Decimal,
/// Fully funded entry goal, fixed at the last plan. Reconcile against
/// actual holdings before repricing, never against today's market value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_quantity: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolPositionActionBasis {
pub generation: String,
pub first_execution_date: NaiveDate,
pub quantity: Decimal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolExecutionState {
pub schema_version: u32,
pub last_execution_date: Option<NaiveDate>,
pub entries: BTreeMap<String, StockPoolEntryProgress>,
#[serde(default)]
pub last_target_weights: BTreeMap<String, i32>,
/// First signal excluding an actually held member; not an acquisition date.
pub removed_since: BTreeMap<String, NaiveDate>,
/// Signal progress, not a fill or holding-period fact. Kept across retries
/// and later execution sessions until a new generation supersedes it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
}
pub struct StockPoolGoalObservation<'a> {
pub symbol: &'a str,
pub target_weight_bps: i32,
pub target_value: Decimal,
pub current_quantity: Decimal,
pub target_quantity: Decimal,
pub status: &'a str,
}
impl Default for StockPoolExecutionState {
fn default() -> Self {
Self {
schema_version: 1,
last_execution_date: None,
entries: BTreeMap::new(),
last_target_weights: BTreeMap::new(),
removed_since: BTreeMap::new(),
position_action_bases: BTreeMap::new(),
}
}
}
impl StockPoolExecutionState {
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != 1
|| self.entries.len() > 10000
|| self.removed_since.len() > 10000
|| self.position_action_bases.len() > 10000
{
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
}
for symbol in self
.entries
.keys()
.chain(self.removed_since.keys())
.chain(self.last_target_weights.keys())
.chain(self.position_action_bases.keys())
{
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
return Err("stock_pool_execution_state_invalid_symbol".into());
}
}
if self.last_target_weights.len() > 10000
|| self
.last_target_weights
.values()
.any(|value| !(0..=10000).contains(value))
{
return Err("stock_pool_execution_state_invalid_weights".into());
}
if self.entries.values().any(|entry| {
entry.latest_target_value < Decimal::ZERO
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|| entry.latest_generation.is_empty()
|| self
.last_execution_date
.is_none_or(|last| entry.first_decision_date > last)
}) || self
.removed_since
.values()
.any(|day| self.last_execution_date.is_none_or(|last| *day > last))
{
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
}
if self.position_action_bases.values().any(|basis| {
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
}) {
return Err("stock_pool_execution_state_invalid_action_basis".into());
}
Ok(())
}
pub fn observe(
&self,
decision_date: NaiveDate,
execution_date: NaiveDate,
official_dates: &[NaiveDate],
members: &[StockPoolMemberSpec],
positions: &[Position],
) -> Result<Self, String> {
self.validate()?;
if decision_date > execution_date
|| !official_dates.contains(&execution_date)
|| !official_dates.contains(&decision_date)
|| official_dates.windows(2).any(|pair| pair[0] >= pair[1])
|| self
.last_execution_date
.is_some_and(|last| last > execution_date)
{
return Err("stock_pool_execution_state_requires_monotone_official_clock".into());
}
let mut next = self.clone();
next.last_execution_date = Some(execution_date);
let members = members
.iter()
.map(|member| member.symbol.clone())
.collect::<BTreeSet<_>>();
let held = positions
.iter()
.filter(|position| position.quantity > Decimal::ZERO)
.map(|position| position.symbol.clone())
.collect::<BTreeSet<_>>();
next.entries.retain(|symbol, entry| {
// Confirmed flat starts a new cycle. A still-unfilled fresh target
// may remain pending while the latest pool still requests it.
!(entry.observed_holding && !held.contains(symbol))
&& (members.contains(symbol) || held.contains(symbol))
});
next.last_target_weights
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
for (symbol, entry) in &mut next.entries {
entry.observed_holding |= held.contains(symbol);
if entry.pending
&& entry.completion_quantity.is_some_and(|goal| {
positions.iter().any(|position| {
&position.symbol == symbol && position.quantity >= goal
})
})
{
entry.pending = false;
}
}
next.removed_since
.retain(|symbol, _| held.contains(symbol) && !members.contains(symbol));
for symbol in held.difference(&members) {
next.removed_since
.entry(symbol.clone())
.or_insert(decision_date);
}
next.validate()?;
Ok(next)
}
pub fn pending_symbols(&self) -> BTreeSet<String> {
self.entries
.iter()
.filter(|(_, entry)| entry.pending)
.map(|(symbol, _)| symbol.clone())
.collect()
}
pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet<String> {
self.removed_since
.iter()
.filter(|(_, removed)| **removed < execution_date)
.map(|(symbol, _)| symbol.clone())
.collect()
}
pub fn record_plan(
&self,
decision_date: NaiveDate,
generation: &str,
plan: &StockPoolPlan,
) -> Result<Self, String> {
self.record_targets(
decision_date,
generation,
plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
symbol: &row.symbol,
target_weight_bps: row.target_weight_bps,
target_value: row.target_value,
current_quantity: row.current_quantity,
target_quantity: row.target_quantity,
status: &row.status,
}),
)?.record_position_action_bases(generation, &plan.position_action_bases)
}
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
self.position_action_bases.iter()
.filter(|(_, basis)| basis.generation == generation)
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
.collect()
}
/// A verified split changes the share unit, not the intended reduction or
/// entry completion. Never infer a split from a changed holding quantity.
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
self.validate()?;
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
return Err("stock_pool_execution_state_split_invalid".into());
}
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
let mut next = self.clone();
if let Some(entry) = next.entries.get_mut(symbol) {
if let Some(quantity) = entry.completion_quantity {
let quantity = scale(quantity)?;
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
}
}
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
basis.quantity = scale(basis.quantity)?;
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
}
next.validate()?;
Ok(next)
}
pub fn record_position_action_bases(
&self,
generation: &str,
quantities: &BTreeMap<String, Decimal>,
) -> Result<Self, String> {
self.validate()?;
if generation.trim().is_empty() {
return Err("stock_pool_execution_state_action_generation_missing".into());
}
let first_execution_date = self.last_execution_date
.ok_or("stock_pool_execution_state_action_clock_missing")?;
let mut next = self.clone();
next.position_action_bases.retain(|_, basis| basis.generation == generation);
for (symbol, quantity) in quantities {
if let Some(basis) = next.position_action_bases.get(symbol) {
if basis.quantity != *quantity {
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
}
} else {
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
generation: generation.into(), first_execution_date, quantity: *quantity,
});
}
}
next.validate()?;
Ok(next)
}
pub fn record_targets<'a>(
&self,
decision_date: NaiveDate,
generation: &str,
rows: impl IntoIterator<Item = StockPoolGoalObservation<'a>>,
) -> Result<Self, String> {
self.validate()?;
if generation.is_empty()
|| self
.last_execution_date
.is_none_or(|date| decision_date > date)
{
return Err("stock_pool_execution_state_plan_clock_invalid".into());
}
let mut next = self.clone();
for row in rows {
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
continue;
}
if row.target_weight_bps > 0 {
next.last_target_weights
.insert(row.symbol.into(), row.target_weight_bps);
}
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO;
let completion_quantity = (row.status == "READY"
&& row.target_quantity > row.current_quantity)
.then_some(row.target_quantity);
let satisfied = matches!(
row.status,
"ALREADY_SATISFIED"
| "ENTRY_TARGET_ALREADY_SATISFIED"
| "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED"
);
if row.current_quantity == Decimal::ZERO && eligible && satisfied {
next.entries.remove(row.symbol);
continue;
}
if let Some(entry) = next.entries.get_mut(row.symbol) {
entry.latest_generation = generation.into();
entry.latest_target_value = row.target_value;
if entry.pending && completion_quantity.is_some() {
entry.completion_quantity = completion_quantity;
}
entry.observed_holding |= row.current_quantity > Decimal::ZERO;
if entry.pending && eligible && satisfied {
entry.pending = false;
}
} else if eligible && row.current_quantity == Decimal::ZERO && !satisfied {
next.entries.insert(
row.symbol.into(),
StockPoolEntryProgress {
pending: true,
observed_holding: false,
first_decision_date: decision_date,
latest_generation: generation.into(),
latest_target_value: row.target_value,
completion_quantity,
},
);
}
}
next.validate()?;
Ok(next)
}
}
+11 -4
View File
@@ -102,6 +102,7 @@ pub struct OpenOrderView {
pub avg_price: f64,
pub transaction_cost: f64,
pub limit_price: f64,
pub reserved_cash: Option<f64>,
pub reason: String,
}
@@ -497,6 +498,7 @@ impl StrategyContext<'_> {
.iter()
.filter(|order| order.side == OrderSide::Buy)
.map(|order| {
if let Some(reserved) = order.reserved_cash { return reserved; }
let price = if order.limit_price.is_finite() {
order.limit_price.max(0.0)
} else {
@@ -1070,6 +1072,9 @@ pub enum TargetPortfolioOrderPricing {
#[derive(Debug, Clone)]
pub enum OrderIntent {
StockPool {
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
},
WithTimeInForce {
intent: Box<OrderIntent>,
time_in_force: OrderTimeInForce,
@@ -1231,6 +1236,7 @@ pub enum OrderIntent {
impl OrderIntent {
fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet<String>) {
match self.unwrapped() {
Self::StockPool { contract } => { symbols.extend(contract.selection.requested_symbols.iter().cloned()); }
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()); }
@@ -1311,6 +1317,7 @@ impl OrderIntent {
pub fn supports_time_in_force(&self, time_in_force: OrderTimeInForce) -> bool {
let intent = self.unwrapped();
if matches!(intent, Self::StockPool { .. }) { return time_in_force == OrderTimeInForce::Day; }
if matches!(
intent,
Self::CancelOrder { .. }
@@ -3179,7 +3186,7 @@ mod tests {
.enumerate()
.map(|(index, date)| DailyMarketSnapshot {
date: *date,
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0 + index as f64,
open: 10.0 + index as f64,
@@ -3205,21 +3212,21 @@ mod tests {
.iter()
.map(|date| DailyFactorSnapshot {
date: *date,
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn: 10.0,
free_float_cap_bn: 9.0,
pe_ttm: 12.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
})
.collect::<Vec<_>>();
let candidate_rows = dates
.iter()
.map(|date| CandidateEligibility {
date: *date,
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
+4
View File
@@ -264,6 +264,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
title: "期货 runtime action 与提交校验".to_string(),
detail: "runtimeExpressions.trading.actions 支持 futures_order、futures_open、futures_close、futures_close_today、futures_close_yesterday;字段包括 symbol、direction=long|short、quantityExpr/amountExpr、可选 limitPriceExpr、transactionCostExpr、whenExpr 和 reason。期货-only 策略把请求初始资金分配给期货账户且股票账户为0;股票+期货混合策略必须显式声明 futuresInitialCash,可选 stockInitialCash。合约必须先由 Source Lake 发布 futures_contract_daily、futures_contract_spec_history、futures_cost_margin_history 三张真实数据集;缺任一张时生成/回测必须失败,禁止手写默认乘数、保证金、费用或价格。订单进入撮合前继续检查上市/退市日期、停牌、trading_phase、限价 tick、涨跌停、反向挂单自成交、保证金和可平今昨仓。".to_string(),
},
ManualSection {
title: "trading.automatic_trade_protection(...)".to_string(),
detail: r#"当前股票/ETF策略的独立自动交易保护:trading.automatic_trade_protection({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]})。配置冻结到 runtimeExpressions.trading.automaticTradeProtection,回测、paper/live 共用内核;不并入全局风控。0/null/未填关闭对应周期;成交日及之后N个完整正式交易日内,买入保护禁止自动卖出及止盈止损,卖出冷却禁止自动增加仓位;只由真实成交启动或延长,拒绝/未成交/撤单不启动。最长持有按首次实际建仓后的正式交易日计数,加仓与部分卖出不重置,清仓后再开仓重置;日期锁定两端包含且高于自动退出,持仓占用真实预算和槽位。人工交易通过独立服务路径执行,仍校验权限、券商及T+1,不接受客户端origin旁路。持仓来源、实际成交或正式日历缺失时明确拒绝;期货与股票期货混合账户尚不支持此能力,不得悄悄忽略。旧trading.max_holding_days仍保留旧含义,不得和新配置声明不同最大周期。"#.to_string(),
},
ManualSection {
title: "trading.rotation / order.* / order.modify / cancel.* / update_universe / subscribe".to_string(),
detail: "支持股票显式下单、期货 runtime action、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 平台内核 的日内订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99, time_in_force=\"gtc\")、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)、account.set_management_fee_rate(0.001)。股票订单和 target_portfolio_smart 支持可选关键字 time_in_force=\"day|ioc|fok|gtc\",编译后写入 runtimeExpressions.trading.actions[].timeInForceDAY 日内保留并在收盘 Expired,IOC 立即撤销未成交余量,FOK 必须全量可成交否则零成交,GTC 仅支持限价单并跨交易日保留;VWAP/TWAP 不接受 FOK/GTC。期货 action 必须由编译器写入结构化 runtimeExpressions,不得让策略源码直接构造 FuturesOrderIntent 或硬编码合约参数。symbol 使用标准证券/合约代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
+4 -4
View File
@@ -83,7 +83,7 @@ impl SelectionContext<'_> {
for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
if self
.dynamic_universe
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol))
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(factor.symbol.as_str()))
{
continue;
}
@@ -307,7 +307,7 @@ mod tests {
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(),
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: Some("2025-01-02 10:00:00".to_string()),
day_open: price,
open: price,
@@ -333,7 +333,7 @@ mod tests {
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date: d(),
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn,
free_float_cap_bn: market_cap_bn,
pe_ttm: 10.0,
@@ -347,7 +347,7 @@ mod tests {
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
CandidateEligibility {
date: d(),
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st,
is_star_st: false,
is_new_listing: false,
@@ -0,0 +1,338 @@
use chrono::NaiveDate;
use fidc_core::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, DataSet, Instrument,
MatchingType, OrderSide, PlatformExplicitOrderKind, PlatformExprStrategy,
PlatformExprStrategyConfig, PlatformTradeAction, PriceField,
};
fn d(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
}
fn data() -> DataSet {
let dates = [11, 14, 15, 16, 17, 18].map(d);
DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".into(),
name: "测试".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
delisted_at: None,
status: "active".into(),
}],
dates
.iter()
.map(|date| DailyMarketSnapshot {
date: *date,
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".into()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
})
.collect(),
dates
.iter()
.map(|date| fidc_core::DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.0),
extra_factors: Default::default(),
})
.collect(),
dates
.iter()
.map(|date| CandidateEligibility {
date: *date,
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
})
.collect(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000300.SH".into(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 1_000_000,
})
.collect(),
)
.unwrap()
}
fn action(quantity: &str, when: &str) -> PlatformTradeAction {
PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::Shares,
symbol: "000001.SZ".into(),
amount_expr: quantity.into(),
when_expr: Some(when.into()),
limit_price_expr: None,
time_in_force: None,
start_time_expr: None,
end_time_expr: None,
reason: "configured_strategy_action".into(),
}
}
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
config.automatic_trade_protection = policy;
config.explicit_actions = vec![
action(
"100",
"decision_date == \"2026-09-11\" || decision_date == \"2026-09-18\"",
),
action("-100", "decision_date >= \"2026-09-14\""),
];
config.matching_type = MatchingType::CurrentBarClose;
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
BacktestEngine::new(
data(),
PlatformExprStrategy::new(config),
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".into(),
start_date: Some(d(11)),
end_date: Some(d(18)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap()
}
#[test]
fn framework_protection_uses_fills_and_covers_explicit_strategy_orders() {
let result = run(AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
});
assert_eq!(
result
.fills
.iter()
.map(|fill| (fill.date, fill.side, fill.quantity))
.collect::<Vec<_>>(),
vec![(d(11), OrderSide::Buy, 100), (d(17), OrderSide::Sell, 100)]
);
assert!(!result.order_events.iter().any(|order| order.date == d(14)
|| order.date == d(15)
|| order.date == d(16)
|| order.date == d(18)));
}
#[test]
fn absolute_lock_blocks_initial_strategy_buy_without_a_rejected_order() {
let result = run(AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(11),
end_date: None,
}],
..Default::default()
});
assert!(result.fills.is_empty());
assert!(result.order_events.is_empty());
}
#[test]
fn maximum_holding_policy_applies_to_discrete_strategies_and_yields_to_buy_protection() {
let result = run(AutomaticTradeProtection {
max_holding_days: 1,
buy_protection_days: 3,
sell_cooldown_days: 3,
..Default::default()
});
assert_eq!(
result
.fills
.iter()
.map(|fill| (fill.date, fill.side))
.collect::<Vec<_>>(),
vec![(d(11), OrderSide::Buy), (d(17), OrderSide::Sell)]
);
assert!(
result
.order_events
.iter()
.any(|order| order.reason == "max_holding_days_exit")
);
}
#[test]
fn serialized_framework_policy_survives_shared_alias_normalization_and_rejects_conflicts() {
let policy = serde_json::json!({"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":null}]});
for key in ["automaticTradeProtection", "automatic_trade_protection"] {
let value = serde_json::json!({"runtimeExpressions":{"trading":{key:policy}}});
let cfg = fidc_core::platform_expr_config_from_value("test", "000001.SZ", &value).unwrap();
assert_eq!(cfg.automatic_trade_protection.buy_protection_days, 3);
assert_eq!(cfg.max_holding_days, Some(90));
assert_eq!(cfg.automatic_trade_protection.locks.len(), 1);
}
let conflict = serde_json::json!({"runtimeExpressions":{"trading":{"maxHoldingDays":30,"automaticTradeProtection":policy}}});
assert!(
fidc_core::platform_expr_config_from_value("test", "000001.SZ", &conflict)
.unwrap_err()
.to_string()
.contains("conflicting maximum")
);
let unknown = serde_json::json!({"runtimeExpressions":{"trading":{"automaticTradeProtection":{"origin":"manual"}}}});
assert!(fidc_core::platform_expr_config_from_value("test", "000001.SZ", &unknown).is_err());
}
#[test]
fn locked_holding_keeps_its_slot_even_when_cash_can_buy_the_next_candidate() {
let base = data();
let dates = [11, 14, 15, 16, 17, 18].map(d);
let symbols = ["000001.SZ", "000002.SZ"];
let dataset = DataSet::from_components(
symbols
.iter()
.map(|symbol| {
let mut row = base.instruments()["000001.SZ"].clone();
row.symbol = (*symbol).into();
row
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.market(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.factor(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.flat_map(|date| {
symbols.iter().map(|symbol| {
let mut row = base.candidate(*date, "000001.SZ").unwrap().clone();
row.symbol = (*symbol).into();
row
})
})
.collect(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000300.SH".into(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 100_000,
})
.collect(),
)
.unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.strategy_name = "protection_test".into();
config.max_positions = 1;
config.selection_limit_expr = "1".into();
config.refresh_rate = 1;
config.exposure_expr = "0.5".into();
config.market_cap_lower_expr = "0".into();
config.market_cap_upper_expr = "100".into();
config.stock_filter_expr="(decision_date == \"2026-09-11\" && symbol == \"000001.SZ\") || (decision_date != \"2026-09-11\" && symbol == \"000002.SZ\")".into();
config.automatic_trade_protection = AutomaticTradeProtection {
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(14),
end_date: Some(d(16)),
}],
..Default::default()
};
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let result = BacktestEngine::new(
dataset,
PlatformExprStrategy::new(config),
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".into(),
start_date: Some(d(11)),
end_date: Some(d(18)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
assert_eq!(
result
.fills
.first()
.map(|fill| (fill.symbol.as_str(), fill.date)),
Some(("000001.SZ", d(11)))
);
assert!(
!result
.fills
.iter()
.any(|fill| [d(14), d(15), d(16)].contains(&fill.date)),
"{:?}",
result.fills
);
assert!(
result.fills.iter().any(|fill| fill.symbol == "000002.SZ"
&& fill.side == OrderSide::Buy
&& fill.date == d(17)),
"{:?}",
result.fills
);
}
+3 -3
View File
@@ -15,7 +15,7 @@ fn d(year: i32, month: u32, day: u32) -> NaiveDate {
fn candidate() -> CandidateEligibility {
CandidateEligibility {
date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -31,7 +31,7 @@ fn candidate() -> CandidateEligibility {
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2024-01-03 10:18:00".to_string()),
day_open: open,
open,
@@ -256,7 +256,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
let snapshot = DailyMarketSnapshot {
date: d(2024, 4, 7),
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2024-04-07 10:18:00".to_string()),
day_open: 2.53,
open: 2.53,
+18 -18
View File
@@ -113,7 +113,7 @@ impl Strategy for BuyAndHoldStrategy {
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0,
open: 10.0,
@@ -139,21 +139,21 @@ fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
}
}
fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -195,7 +195,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![
DailyMarketSnapshot {
date: buy_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-01 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -218,7 +218,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
DailyMarketSnapshot {
date: ex_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -241,7 +241,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
DailyMarketSnapshot {
date: payable_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -266,42 +266,42 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![
DailyFactorSnapshot {
date: buy_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: ex_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: payable_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: buy_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -314,7 +314,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
CandidateEligibility {
date: ex_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -327,7 +327,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
},
CandidateEligibility {
date: payable_date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -394,7 +394,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
.with_minimum_commission(0.0),
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig {
initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(),
@@ -475,7 +475,7 @@ fn engine_settles_same_day_dividend_after_split_for_aiquant_semantics() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
BacktestConfig {
initial_cash: 11_008.0,
benchmark_code: "000300.SH".to_string(),
@@ -2,10 +2,10 @@ use chrono::{Duration, NaiveDate, NaiveTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -16,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, minute, second).expect("valid time")
}
fn fixture_instruments() -> Vec<Instrument> {
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "quote-plan-fixture".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}]
}
#[derive(Default)]
struct DecisionQuoteReader {
day_count: usize,
@@ -90,10 +102,10 @@ impl Strategy for NoLoaderDecisionQuoteStrategy {
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
DataSet::from_components(
Vec::new(),
fixture_instruments(),
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0,
open: 10.0,
@@ -116,7 +128,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -127,7 +139,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -151,14 +163,55 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}
#[test]
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
fn runtime_account_dependent_quote_scope_uses_the_actual_account() {
struct AccountDependentQuoteReader;
impl Strategy for AccountDependentQuoteReader {
fn name(&self) -> &str { "account_dependent_quote_reader" }
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> {
Ok(if ctx.portfolio.cash() < 50_000.0 {
BTreeSet::from(["000001.SZ".into()])
} else { BTreeSet::new() })
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
let loaded = ctx.data.execution_quotes_on(ctx.execution_date, "000001.SZ").iter().any(|quote|
quote.timestamp.time()==t(10,17,59) && quote.last_price==10.0);
assert_eq!(loaded, ctx.portfolio.cash() < 50_000.0,
"quote scope must match this account, not a fixed-capital planning account");
Ok(StrategyDecision::default())
}
}
let date = d(2026, 1, 5);
for initial_cash in [10_000.0, 100_000.0] {
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close,
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash, benchmark_code:"000852.SH".into(),
start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0,
execution_price_field:PriceField::Close,
};
let mut engine = BacktestEngine::new(single_day_quote_plan_data(date), AccountDependentQuoteReader, broker, config)
.with_execution_quote_loader(move |request| Ok(request.symbols.into_iter().map(|symbol| IntradayExecutionQuote {
observation_kind:Default::default(), 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".into()),
}).collect()));
engine.run().expect("account-dependent quote planning");
}
}
#[test]
fn engine_resolves_the_runtime_strategy_scope_when_a_loader_exists() {
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_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
@@ -174,17 +227,13 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
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 {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 17, 59)),
@@ -198,20 +247,19 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
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"
1,
"quote planning must use the actual run context"
);
assert_eq!(
*loader_calls.lock().expect("loader counter mutex"),
1,
"the supplied symbols must still pass through the normal quote loader"
0,
"an empty runtime scope must not fetch unrequested symbols"
);
}
@@ -223,7 +271,7 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Close,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::CurrentBarClose);
let config = BacktestConfig {
initial_cash: 10_000.0,
@@ -253,11 +301,11 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components(
Vec::new(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -280,7 +328,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
@@ -305,7 +353,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -316,7 +364,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -329,7 +377,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -342,7 +390,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -379,7 +427,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
@@ -399,7 +447,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 39, 59)),
@@ -423,11 +471,11 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components_with_actions_and_quotes(
Vec::new(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -450,7 +498,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
@@ -475,7 +523,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -486,7 +534,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -499,7 +547,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -512,7 +560,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -544,7 +592,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: first,
symbol: "000001.SZ".to_string(),
timestamp: first.and_time(t(10, 39, 59)),
@@ -557,7 +605,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: second,
symbol: "000001.SZ".to_string(),
timestamp: second.and_time(t(10, 39, 59)),
@@ -578,7 +626,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
@@ -658,11 +706,11 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
let first = d(2026, 1, 5);
let second = d(2026, 1, 6);
let data = DataSet::from_components(
Vec::new(),
fixture_instruments(),
vec![
DailyMarketSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -685,7 +733,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
},
DailyMarketSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5,
open: 10.5,
@@ -710,7 +758,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![
DailyFactorSnapshot {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -721,7 +769,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
},
DailyFactorSnapshot {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 10.0,
free_float_cap_bn: 10.0,
pe_ttm: 10.0,
@@ -734,7 +782,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![
CandidateEligibility {
date: first,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -747,7 +795,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
},
CandidateEligibility {
date: second,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -784,7 +832,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
.with_matching_type(MatchingType::MinuteLast)
.with_intraday_execution_start_time(t(10, 40, 0));
let config = BacktestConfig {
@@ -814,7 +862,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
+30 -30
View File
@@ -71,7 +71,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -94,7 +94,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
DailyMarketSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 5.0,
open: 5.0,
@@ -117,7 +117,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
DailyMarketSnapshot {
date: delist_date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 5.05,
open: 5.05,
@@ -140,7 +140,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 5.1,
open: 5.1,
@@ -165,53 +165,53 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![
DailyFactorSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 30.0,
free_float_cap_bn: 28.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: delist_date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 30.5,
free_float_cap_bn: 28.5,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 31.0,
free_float_cap_bn: 29.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -224,7 +224,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -237,7 +237,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
CandidateEligibility {
date: delist_date,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -250,7 +250,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -295,7 +295,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new(
data,
BuyThenHoldStrategy,
@@ -364,7 +364,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
@@ -387,7 +387,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
DailyMarketSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 20.0,
open: 20.0,
@@ -410,7 +410,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
DailyMarketSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 21.0,
open: 21.0,
@@ -435,42 +435,42 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![
DailyFactorSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 30.0,
free_float_cap_bn: 28.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
DailyFactorSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
market_cap_bn: 31.0,
free_float_cap_bn: 29.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(),
extra_factors: Default::default(),
},
],
vec![
CandidateEligibility {
date: date1,
symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -483,7 +483,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -496,7 +496,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
symbol: "000002.SZ".into(),
is_st: false,
is_star_st: false,
is_new_listing: false,
@@ -548,7 +548,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit);
let mut engine = BacktestEngine::new(
data,
BuyThenHoldStrategy,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
{
"strategyId": "fixture_hold_without_stops_backtest",
"version": "1.0.0",
"market": "CN_A",
"benchmark": {
"instrumentId": "000300.SH",
"fallbackInstrumentId": "000300.SH",
"note": "必须使用真实指数链路;若 000852.SH 不可用,应直接报错而不是退化到其他标的。"
},
"universe": {
"exclude": [],
"implementationNotes": [
"ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行",
"上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断",
"盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动"
],
"include": [
"000001.SZ",
"000002.SZ"
]
},
"selectors": [
{
"type": "dynamicRange",
"field": "market_cap",
"lowerExpr": "0",
"upperExpr": "1000000000000",
"mapping": "close -> strategy_factory_source_lake.runtime_fields.close"
},
{
"type": "filter",
"expr": "(close > 0)"
},
{
"type": "rank",
"orderBy": [
"market_cap asc"
],
"limitExpr": "2"
}
],
"rebalance": {
"frequencyDays": 1,
"tradeTimes": [],
"dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合",
"schedule": {
"frequency": "daily"
}
},
"risk": {
"takeProfitExpr": "",
"stopLossExpr": "",
"indexThrottleExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
"stopTakeReferencePriceMode": "position_average_entry_price"
},
"seasonality": {
"skipWindows": []
},
"execution": {
"selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1",
"executionGranularity": "daily_or_minute_bar",
"priceSource": "current_bar_close_or_next_bar_open_or_minute_bar",
"matchingType": "current_bar_close",
"rebalanceCashMode": "sell_then_buy",
"slippageModel": "none",
"slippageValue": 0,
"riskPolicy": {
"rejectStSelection": false,
"rejectStarStSelection": false,
"rejectPausedSelection": false,
"rejectInactiveSelection": false,
"rejectNewListingSelection": false,
"rejectKcbSelection": false,
"rejectBjseSelection": false,
"rejectOneYuanSelection": false,
"rejectUpperLimitSelection": false,
"rejectLowerLimitSelection": false,
"rejectStBuy": true,
"rejectStarStBuy": true,
"rejectPausedBuy": true,
"rejectInactiveBuy": true,
"rejectNewListingBuy": true,
"rejectKcbBuy": true,
"rejectBjseBuy": true,
"rejectOneYuanBuy": true,
"rejectUpperLimitBuy": true,
"rejectPausedSell": true,
"rejectInactiveSell": true,
"rejectLowerLimitSell": true,
"respectAllowBuySell": true,
"forbidSameDayRebuyAfterSell": true,
"blacklistEnabled": true,
"blacklistedSymbols": [],
"allowMarketOrders": true,
"liveTradingEnabled": false,
"volumeLimitEnabled": true,
"liquidityLimitEnabled": true,
"volumePercent": 0.25,
"maxOrderQuantity": 1000000,
"maxOrderNotional": 100000000,
"maxSymbolPosition": 10000000,
"commissionRate": 0.0003,
"minimumCommission": 5,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28"
},
"sourceLanguage": "engine-script",
"sourceKind": "platform-strategy",
"extractor": "omniquant-engine-script-v2",
"sellThenBuyDelaySlippageRate": 0,
"strictValueBudget": true
},
"factorRefs": [
"close"
],
"runtimeExpressions": {
"prelude": "",
"schedule": {
"frequency": "daily"
},
"selection": {
"limitExpr": "2",
"candidateLimitExpr": "2",
"marketCapField": "close",
"marketCapLowerExpr": "0",
"marketCapUpperExpr": "1000000000000",
"stockFilterExpr": "(close > 0)"
},
"risk": {
"exposureExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
"stopLossExpr": "",
"takeProfitExpr": "",
"stopTakeReferencePriceMode": "position_average_entry_price"
},
"allocation": {
"buyScaleExpr": "1.0"
},
"ordering": {
"rankBy": "market_cap",
"rankExpr": "(symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2",
"rankOrder": "asc"
},
"trading": {
"rotationEnabled": true,
"subscriptionGuardRequired": false,
"stage": "on_day",
"actions": []
}
},
"engineConfig": {
"templateId": "fixture_hold_without_stops_backtest",
"benchmarkSymbol": "000300.SH",
"signalSymbol": "000300.SH",
"rankLimit": 2,
"refreshRate": 1,
"rsiRate": 1.0001,
"dynamicRange": {
"baseIndexLevel": 2000,
"baseCapFloor": 7,
"capSpan": 1000000000000,
"xs": 0.008
},
"stopLossMultiplier": null,
"takeProfitMultiplier": null,
"matchingType": "current_bar_close",
"rebalanceCashMode": "sell_then_buy",
"slippageModel": "none",
"slippageValue": 0,
"riskPolicy": {
"rejectStSelection": false,
"rejectStarStSelection": false,
"rejectPausedSelection": false,
"rejectInactiveSelection": false,
"rejectNewListingSelection": false,
"rejectKcbSelection": false,
"rejectBjseSelection": false,
"rejectOneYuanSelection": false,
"rejectUpperLimitSelection": false,
"rejectLowerLimitSelection": false,
"rejectStBuy": true,
"rejectStarStBuy": true,
"rejectPausedBuy": true,
"rejectInactiveBuy": true,
"rejectNewListingBuy": true,
"rejectKcbBuy": true,
"rejectBjseBuy": true,
"rejectOneYuanBuy": true,
"rejectUpperLimitBuy": true,
"rejectPausedSell": true,
"rejectInactiveSell": true,
"rejectLowerLimitSell": true,
"respectAllowBuySell": true,
"forbidSameDayRebuyAfterSell": true,
"blacklistEnabled": true,
"blacklistedSymbols": [],
"allowMarketOrders": true,
"liveTradingEnabled": false,
"volumeLimitEnabled": true,
"liquidityLimitEnabled": true,
"volumePercent": 0.25,
"maxOrderQuantity": 1000000,
"maxOrderNotional": 100000000,
"maxSymbolPosition": 10000000,
"commissionRate": 0.0003,
"minimumCommission": 5,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28"
},
"skipWindows": [],
"rebalanceSchedule": {
"frequency": "daily"
},
"dividendReinvestment": false,
"sellThenBuyDelaySlippageRate": 0,
"strictValueBudget": true
},
"stockPool": {
"schema_version": 1,
"pool_id": "fixture-pool",
"version_id": "fixture-version",
"members": [
{
"symbol": "000001.SZ",
"requested_order": 0,
"recommendation_reason": "",
"target_weight_bps": null,
"stop_loss": null,
"take_profit": null
},
{
"symbol": "000002.SZ",
"requested_order": 1,
"recommendation_reason": "",
"target_weight_bps": null,
"stop_loss": null,
"take_profit": null
}
],
"allocation_policy": {
"target_holding_count": 2,
"portfolio_policy": {
"schema_version": 1,
"membership": "retain_holdings",
"rebalance_weights": false
},
"invest_ratio_bps": 10000,
"reserve_cash": 0
},
"timing_policy": {
"schema_version": 1,
"auto_execute": true,
"freeze_time": "00:00",
"window_start": "09:30",
"window_end": "15:00",
"trigger_mode": "scheduled_bar",
"pricing_mode": "first_tick",
"automatic_trade_protection": {
"buy_protection_days": 0,
"sell_cooldown_days": 0,
"max_holding_days": 0,
"locks": []
}
},
"stop_take_policy": {
"stop_loss": null,
"take_profit": null
},
"out_of_pool_policy": "hold"
},
"signalSymbol": "000300.SH",
"sourceCode": "strategy(\"fixture_hold_without_stops_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1)\n universe.include([\"000001.SZ\", \"000002.SZ\"])\n selection.limit(2)\n selection.candidate_limit(2)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr(close > 0)\n ordering.rank_expr((symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2, \"asc\")\n risk.index_exposure(max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale(1.0)\n stock_pool.config({\"schema_version\":1,\"pool_id\":\"fixture-pool\",\"version_id\":\"fixture-version\",\"members\":[{\"symbol\":\"000001.SZ\",\"requested_order\":0,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null},{\"symbol\":\"000002.SZ\",\"requested_order\":1,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null}],\"allocation_policy\":{\"target_holding_count\":2,\"portfolio_policy\":{\"schema_version\":1,\"membership\":\"retain_holdings\",\"rebalance_weights\":false},\"invest_ratio_bps\":10000,\"reserve_cash\":0},\"timing_policy\":{\"schema_version\":1,\"auto_execute\":true,\"freeze_time\":\"00:00\",\"window_start\":\"09:30\",\"window_end\":\"15:00\",\"trigger_mode\":\"scheduled_bar\",\"pricing_mode\":\"first_tick\",\"automatic_trade_protection\":{\"buy_protection_days\":0,\"sell_cooldown_days\":0,\"max_holding_days\":0,\"locks\":[]}},\"stop_take_policy\":{\"stop_loss\":null,\"take_profit\":null},\"out_of_pool_policy\":\"hold\"})\n risk.reference_price_mode(\"position_average_entry_price\")\n execution.matching_type(\"current_bar_close\")\n}\n",
"mode": "rotation"
}
@@ -17,7 +17,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
.iter()
.map(|date| DailyMarketSnapshot {
date: *date,
symbol: SYMBOL.to_string(),
symbol: SYMBOL.into(),
timestamp: None,
day_open: 10.0,
open: 10.0,
@@ -55,7 +55,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
for offset in 0..bars_per_day {
let timestamp = session_start + Duration::minutes(offset as i64);
quotes.push(IntradayExecutionQuote {
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date: *date,
symbol: SYMBOL.to_string(),
timestamp,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
use chrono::NaiveDate;
use fidc_core::stock_pool_execution::*;
use fidc_core::stock_pool_state::StockPoolExecutionState;
use rust_decimal::Decimal;
use serde_json::json;
use std::collections::BTreeMap;
fn day(value: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
}
fn member() -> StockPoolMemberSpec {
StockPoolMemberSpec {
symbol: "000001.SZ".into(),
requested_order: 0,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
}
}
fn held(quantity: i64, closable: i64) -> Position {
Position {
symbol: "000001.SZ".into(),
quantity: quantity.into(),
closable_quantity: closable.into(),
average_cost: 10.into(),
}
}
fn quote() -> MarketSnapshot {
MarketSnapshot {
symbol: "000001.SZ".into(),
last_price: 10.into(),
prev_close: Some(10.into()),
volume: Some(1000000.into()),
turnover: Some(10000000.into()),
bid_price_1: Some(10.into()),
ask_price_1: Some(10.into()),
is_kcb: Some(false),
instrument_rules: None,
buy_sizing_price: None,
sell_sizing_price: None,
}
}
fn plan(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
) -> StockPoolPlan {
plan_at_price(state, at, members, positions, cash, outside, 10)
}
fn plan_at_price(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
price: i64,
) -> StockPoolPlan {
let symbols = members
.iter()
.map(|member| member.symbol.clone())
.collect::<Vec<_>>();
let selection = StockPoolSelection {
trade_date: at,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols.clone(),
final_symbols: symbols,
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some("same-goal".into()),
};
let mut constraints = stock_pool_constraints_from_configuration(
&json!({"top_n_rebalance_policy":"preserve_existing"}),
&json!({}),
)
.unwrap();
constraints.pending_entry_symbols = state.pending_symbols();
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
let mut market = quote();
market.last_price = price.into();
market.bid_price_1 = Some(price.into());
market.ask_price_1 = Some(price.into());
build_stock_pool_target_plan_with_constraints(
&selection,
members,
&StockPoolExecutionRule::default(),
&AccountSnapshot {
total_equity: 10000.into(),
cash: cash.into(),
frozen_cash: Decimal::ZERO,
},
positions,
&[market],
10000,
Decimal::ZERO,
outside,
"preserve_existing",
&constraints,
"same-goal",
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
)
.unwrap()
}
#[test]
fn fully_filled_entry_is_not_reopened_when_price_falls_before_next_observation() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 10000, "hold");
let state = initial.record_plan(day(11), "first-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, Some(1000.into()));
let state: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
let observed = state.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]).unwrap();
assert!(!observed.pending_symbols().contains("000001.SZ"));
let next = plan_at_price(&observed, day(14), &members, &[held(1000, 1000)], 1000, "hold", 8);
assert_eq!(next.rows[0].target_quantity, 1000.into());
assert_eq!(next.rows[0].delta_quantity, Decimal::ZERO);
assert_eq!(next.rows[0].status, "PRESERVED_EXISTING_POSITION");
}
#[test]
fn cash_clipped_or_rejected_entry_does_not_claim_full_completion() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 5000, "hold");
assert_eq!(first_plan.rows[0].status, "REDUCE_TO_ALLOWED_QUANTITY");
let state = initial.record_plan(day(11), "limited-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, None);
for quantity in [0, 500] {
let positions = if quantity == 0 { vec![] } else { vec![held(quantity, quantity)] };
let observed = state.observe(day(14), day(14), &calendar, &members, &positions).unwrap();
assert!(observed.pending_symbols().contains("000001.SZ"));
}
}
#[test]
fn legacy_state_without_quantity_keeps_its_serialized_identity() {
let original = json!({
"schema_version":1,"last_execution_date":"2026-09-11",
"entries":{"000001.SZ":{"pending":true,"observed_holding":false,
"first_decision_date":"2026-09-11","latest_generation":"legacy",
"latest_target_value":"10000"}},"last_target_weights":{},"removed_since":{}
});
let state: StockPoolExecutionState = serde_json::from_value(original.clone()).unwrap();
state.validate().unwrap();
assert_eq!(serde_json::to_value(state).unwrap(), original);
}
#[test]
fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
let original = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
assert!(next_day.position_action_bases_for("new-signal").is_empty());
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
}
assert!(original.record_position_action_bases(" ", &basis).is_err());
}
#[test]
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
}
#[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let first = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[])
.unwrap();
let initial = plan(&first, day(11), &members, &[], 10000, "hold");
assert_eq!(initial.rows[0].target_quantity, 1000.into());
let pending = first.record_plan(day(11), "same-goal", &initial).unwrap();
assert!(pending.entries["000001.SZ"].pending);
assert!(!pending.entries["000001.SZ"].observed_holding);
let persisted = serde_json::to_vec(&pending).unwrap();
let restored: StockPoolExecutionState = serde_json::from_slice(&persisted).unwrap();
let partial = restored
.observe(day(11), day(11), &calendar, &members, &[held(500, 0)])
.unwrap();
let retry = plan(&partial, day(11), &members, &[held(500, 0)], 5000, "hold");
assert_eq!(retry.rows[0].delta_quantity, 500.into(), "{retry:?}");
let pending = partial.record_plan(day(11), "same-goal", &retry).unwrap();
assert!(pending.entries["000001.SZ"].pending);
let filled = pending
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
.unwrap();
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
assert_eq!(satisfied.rows[0].status, "PRESERVED_EXISTING_POSITION");
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
assert!(!completed.entries["000001.SZ"].pending);
assert_eq!(
plan(
&completed,
day(14),
&members,
&[held(1000, 1000)],
0,
"hold"
)
.rows[0]
.status,
"PRESERVED_EXISTING_POSITION"
);
}
#[test]
fn removal_anchor_is_not_reset_by_rechecks_weekends_or_t_plus_one() {
let calendar = vec![day(11), day(14), day(15)];
let positions = vec![held(1000, 1000)];
let removed = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &[], &positions)
.unwrap();
assert_eq!(removed.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&removed,
day(11),
&[],
&positions,
0,
"reduce_next_trading_day"
)
.rows[0]
.status,
"DEFERRED_T_PLUS_ONE"
);
assert!(
removed
.observe(day(12), day(12), &calendar, &[], &positions)
.is_err()
);
let restored: StockPoolExecutionState =
serde_json::from_str(&serde_json::to_string(&removed).unwrap()).unwrap();
let monday = restored
.observe(day(14), day(14), &calendar, &[], &[held(1000, 0)])
.unwrap();
assert_eq!(monday.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&monday,
day(14),
&[],
&[held(1000, 0)],
0,
"reduce_next_trading_day"
)
.rows[0]
.delta_quantity,
Decimal::ZERO
);
let next = monday
.observe(day(15), day(15), &calendar, &[], &positions)
.unwrap();
let exit = plan(
&next,
day(15),
&[],
&positions,
0,
"reduce_next_trading_day",
);
assert_eq!(exit.rows[0].target_quantity, Decimal::ZERO);
assert_eq!(exit.rows[0].side, Some(OrderSide::Sell));
let returned = next
.observe(day(15), day(15), &calendar, &[member()], &positions)
.unwrap();
assert!(returned.removed_since.is_empty());
}
#[test]
fn cloned_preview_does_not_start_a_timer_and_next_open_uses_signal_removal_date() {
let state = StockPoolExecutionState::default();
let calendar = vec![day(11), day(14)];
let preview = state
.observe(day(11), day(14), &calendar, &[], &[held(1000, 1000)])
.unwrap();
assert!(state.removed_since.is_empty());
assert!(preview.next_day_exit_symbols(day(14)).contains("000001.SZ"));
let mut invalid = preview;
invalid.schema_version = 0;
assert!(invalid.validate().is_err());
}
+3 -3
View File
@@ -35,7 +35,7 @@ fn market(
) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date: d(date),
symbol: symbol.to_string(),
symbol: symbol.into(),
timestamp: None,
day_open: open,
open,
@@ -66,7 +66,7 @@ fn factor(
) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date: d(date),
symbol: symbol.to_string(),
symbol: symbol.into(),
market_cap_bn,
free_float_cap_bn,
pe_ttm: 18.0,
@@ -87,7 +87,7 @@ fn candidate(
) -> CandidateEligibility {
CandidateEligibility {
date: d(date),
symbol: symbol.to_string(),
symbol: symbol.into(),
is_st: false,
is_star_st: false,
is_new_listing,
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "fidc-signal-client"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
fidc-core = { path = "../fidc-core" }
reqwest.workspace = true
serde_json.workspace = true
+43
View File
@@ -0,0 +1,43 @@
//! Shared signal transport for FIDC backtest and trading services.
use std::sync::Arc;
use fidc_core::signal_contract::{SignalBookReference,ValidatedSignalBook,cached_signal_book,register_signal_book};
use reqwest::Client;
use serde_json::{Value,json};
#[derive(Clone,Copy)]
pub enum Purpose { Backtest, Online }
pub async fn load(client:&Client, source_url:&str, token:&str, reference:&SignalBookReference, purpose:Purpose)
-> Result<Arc<ValidatedSignalBook>,String>
{
reference.validate()?;
if token.len()<32 {return Err("signal_service_auth_not_configured".into());}
let purpose_name=match purpose {Purpose::Backtest=>"backtest",Purpose::Online=>"online"};
let payload=json!({"reference":reference,"purpose":purpose_name});
let root=format!("{}/api/strategy-signals/internal",source_url.trim_end_matches('/'));
// Registration/purpose validation always precedes a process-cache hit.
let response=client.post(format!("{root}/validate"))
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
.map_err(|_|"signal_validation_service_unavailable")?;
if !response.status().is_success() {return Err(format!("signal_validation_rejected_http_{}",response.status()));}
let validation:Value=response.json().await.map_err(|_|"signal_validation_response_invalid")?;
if validation.get("ok")!=Some(&Value::Bool(true)) || validation.get("reference")!=Some(&json!(reference)) {
return Err("signal_validation_identity_mismatch".into());
}
let book=if let Some(book)=cached_signal_book(reference)? {book} else {
let mut response=client.post(format!("{root}/book"))
.header("X-FIDC-Lifecycle-Token",token).json(&payload).send().await
.map_err(|_|"signal_book_service_unavailable")?;
if !response.status().is_success() {return Err(format!("signal_book_rejected_http_{}",response.status()));}
if response.content_length().is_some_and(|bytes|bytes>64*1024*1024) {return Err("signal_book_transport_size_exceeded".into());}
let mut bytes=Vec::new();
while let Some(chunk)=response.chunk().await.map_err(|_|"signal_book_transport_incomplete")? {
if bytes.len().saturating_add(chunk.len())>64*1024*1024 {return Err("signal_book_transport_size_exceeded".into());}
bytes.extend_from_slice(&chunk);
}
register_signal_book(reference,&bytes)?
};
if matches!(purpose,Purpose::Online) {book.require_observed()?;}
Ok(book)
}
@@ -0,0 +1,25 @@
# 策略级自动交易保护
## 统一合同
`runtimeExpressions.trading.automaticTradeProtection` 是每个股票/ETF策略自己的不可变配置。股票池、表达式轮动和显式订单复用 `holding_policy` 内核,不新增全局共享配置,也不修改未配置的历史策略。
```json
{"buy_protection_days":3,"sell_cooldown_days":3,"max_holding_days":90,"locks":[{"symbol":"000001.SZ","start_date":"2026-09-11","end_date":"2026-09-16"}]}
```
- 周期为空、null或0关闭,必须为0—3650整数;锁定支持同股多个区间,起止日包含当日,截止null持续有效。
- 买入保护禁止自动减仓/清仓及止盈止损;卖出冷却禁止自动增加仓位。只有实际成交计时,部分成交延长对应最后成交日;未成交、拒绝、撤单不启动。
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
## 根因补充修复
组合 `decision_date == "2026-09-11" && symbol == "000001.SZ"` 会落到字符串表达式路径。旧代码遗漏日期等内建标识符的保留登记,又按“额外因子”注入NaN,覆盖同名真实日期,造成选股错误。现登记全部已注入内建字段,并禁止额外因子覆盖已存在的作用域变量。单独数字VM日期测试不足以发现该问题,新增日期+证券混合选择回归。
## 验证与边界
原生完整回测测试验证:显式策略真实模拟成交日启动3日保护/禁买、日期锁定零委托、最长持有让位于保护、锁定持仓占据资金与席位、解锁后才按候选顺序买入;序列化和别名归一不改max_holding_days字段,冲突策略拒绝。现有534核心用例通过(6个既有忽略项)。这些是隔离内核测试,不是GT实际成交验收。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,588 @@
{
"schema": "fidc.selection-risk-plan-acceptance/v1",
"rows": [
{
"name": "control-1",
"receiptSha256": "f18b3b484d40e2a813bd795cb38e263ff43f65b17f31004786d3a23a6af5bcb6",
"wallSeconds": 30.986483575077727,
"engineSeconds": 8.79,
"dataSeconds": 8.445,
"validationSeconds": 12.244,
"resultSeconds": 1.292,
"maxRssKiB": 7090392,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "control-2",
"receiptSha256": "8e5f7f8fe77ba2a798056306277a4ae4f00b6aa98b8c277269aca8c235bbd0fb",
"wallSeconds": 13.274638780159876,
"engineSeconds": 6.739,
"dataSeconds": 5.19,
"validationSeconds": 0.209,
"resultSeconds": 1.003,
"maxRssKiB": 7092040,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "control-3",
"receiptSha256": "d106ddae57c64f931e196b80ffa517443e5c0f11eb9c2079f84d55b2d693fb13",
"wallSeconds": 13.043757867999375,
"engineSeconds": 6.732,
"dataSeconds": 5.159,
"validationSeconds": 0.005,
"resultSeconds": 1,
"maxRssKiB": 7089984,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-1",
"receiptSha256": "a76e11c115ad42389dfdf72ed674ad75af8ec3d4646feb57feee9e6a4418f20d",
"wallSeconds": 12.976857921108603,
"engineSeconds": 6.682,
"dataSeconds": 5.132,
"validationSeconds": 0.004,
"resultSeconds": 1.021,
"maxRssKiB": 7091752,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-2",
"receiptSha256": "38f61fd0d495daa5e29d6354679ce51e33473fb3ecbbb420c93d2fd41b74246f",
"wallSeconds": 12.927155625075102,
"engineSeconds": 6.64,
"dataSeconds": 5.128,
"validationSeconds": 0.005,
"resultSeconds": 1.01,
"maxRssKiB": 7092320,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-3",
"receiptSha256": "9b56896d6dc048c5dd3d56cbe863778122b5bdf42fc9769eaa41f2d1b339dcd4",
"wallSeconds": 12.926160736009479,
"engineSeconds": 6.664,
"dataSeconds": 5.113,
"validationSeconds": 0.006,
"resultSeconds": 1.006,
"maxRssKiB": 7091128,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "trend-40-control",
"receiptSha256": "305f0ea34b50355661ef9d3583467f7160cfbffd95f03b9e21a631bebc37af64",
"wallSeconds": 15.628366323187947,
"engineSeconds": 8.199,
"dataSeconds": 5.234,
"validationSeconds": 0.694,
"resultSeconds": 1.33,
"maxRssKiB": 7108340,
"fills": 29776,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"sharedInputsUnchanged": true
},
{
"name": "trend-40-candidate",
"receiptSha256": "0174941bea20079730c019b3de4185cc439528160ab54cafc1be4e3f8a0a08fc",
"wallSeconds": 14.82603678200394,
"engineSeconds": 8.087,
"dataSeconds": 5.276,
"validationSeconds": 0.004,
"resultSeconds": 1.322,
"maxRssKiB": 7108656,
"fills": 29776,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"sharedInputsUnchanged": true
},
{
"name": "pullback-40-control",
"receiptSha256": "0d39c6af608d3ec89fc44d0715dab41229511c68cf5ea4eb01f763c10bded8bf",
"wallSeconds": 13.775856785941869,
"engineSeconds": 7.374,
"dataSeconds": 4.893,
"validationSeconds": 0.005,
"resultSeconds": 1.358,
"maxRssKiB": 7119352,
"fills": 31862,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"sharedInputsUnchanged": true
},
{
"name": "pullback-40-candidate",
"receiptSha256": "3f0ec7b8b6ad74fc7349586a8716d45b8075ebad03c77776ca78fab5188d49ee",
"wallSeconds": 13.927610703045502,
"engineSeconds": 7.239,
"dataSeconds": 5.137,
"validationSeconds": 0.003,
"resultSeconds": 1.368,
"maxRssKiB": 7119784,
"fills": 31862,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"sharedInputsUnchanged": true
},
{
"name": "volume-momentum-80-control",
"receiptSha256": "98239365828453888930a1fceb2a7d9b5402b03cd32c9303a9fa1532af3644ed",
"wallSeconds": 18.176081838086247,
"engineSeconds": 11.154,
"dataSeconds": 4.585,
"validationSeconds": 0.004,
"resultSeconds": 2.268,
"maxRssKiB": 7158556,
"fills": 51300,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"sharedInputsUnchanged": true
},
{
"name": "volume-momentum-80-candidate",
"receiptSha256": "1cbbccd9678bc8ea2754f2ffa678f3d43feb659a58f86393c4c54961daa5a8d0",
"wallSeconds": 18.627057212870568,
"engineSeconds": 11.013,
"dataSeconds": 5.2,
"validationSeconds": 0.005,
"resultSeconds": 2.267,
"maxRssKiB": 7152664,
"fills": 51300,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"sharedInputsUnchanged": true
}
],
"sharedInputFiles": 9257,
"sharedInputBytes": 12596608049,
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
"verifiedFactBlocks": 3506,
"status": "candidate-not-deployed",
"sourceCommit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"engineCommit": "d2aa16a2f0064297d0d8c931060646d66422e9d4",
"serviceCommit": "4e23c7558d8301ba697543c39d5604289bb82c53",
"controlRunnerSha256": "b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d",
"candidateRunnerSha256": "1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb",
"candidateApiSha256": "30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501",
"bounds": [
"All twelve are new runner processes and private result artifacts using the same verified shared input files.",
"Input hashing is outside the elapsed benchmark timer; no GDB samples are in these measurements.",
"The first control had 12.244s Source validation and a slower preparation phase. Its entire latency difference is not candidate speedup.",
"The full input set is identical across the twelve runs, not only a global cache hit counter.",
"No Source/trading service was changed and no paused research/signal task resumed.",
"The independently recorded intraday-clock counterexample remains open. These day-level replays do not close it."
]
}
+54
View File
@@ -0,0 +1,54 @@
# 逐成交腿价格风控验收
## 修复范围
Engine `7e0877b5860d8724da1c4507a1d1ba393b3497f5`Trading `1f7bc074024191cfaa5975546f22c2c2c733602a`,均以 tag `v2026.9.11.2` 发布177。
- 回测在每条实际报价进入撮合前检查原始参考价,滑点和限价处理后再次检查最终价。买入一元股、买入涨停、卖出跌停以及无效价格均按本腿价格处理,不能只依赖最初下单的日线标记或价格。
- Paper和Live的订单前检查与Paper撮合共用`MarketSnapshot::execution_reference_price`:普通买入用卖一、卖出用买一;未提供该侧价格时保留既有最新价合同,显式0或负数不当缺失处理、不回退。
- 选股仍独立使用其日线最新价与显式规则,不被买卖盘差异改写。盘后固定价仍使用原正式收盘价合同。
- Paper已接受/部分成交订单在新报价到达时重新检查。后续被风控拒绝不删除或回滚此前真实模拟成交,不重复扣资金或手续费。
- 实盘这里只验证发单前路径;券商实际成交事实必须原样保存,不能声称本地检查能保证委托进入券商后市场不再变化。本轮未提交证券订单。
## 测试
- 原始报价0.9、正向滑点20%后为1.08,仍不得利用滑点绕过一元股规则。
- 先出现0.9、后出现1.2的报价,只允许在后一个实际时点成交;不回写到前一时点。
- 限价滑点将最终执行价变为0.9时仍拒绝;显式关闭一元股买入规则后放行;卖出不继承买入一元股规则。
- 最后价10而卖一11触及涨停:买入拒绝。最后价11而卖一10.5:执行检查不按旧最后价误拒;显式选股涨停规则仍可按最后价拒绝。
- 最后价10而买一9触及跌停:卖出拒绝。最后价9而买一9.5:执行检查不按旧最后价误拒。
- 原挂单/部分成交后,最后价1.1但卖一0.9:余单拒绝,既有成交数和现金保持不变。
177测试:Engine 667通过/8忽略,Trading工作区548通过/10忽略,Runner370通过/3忽略,API99通过/1忽略。新场景使用隔离合成账户/报价,未以此冒充原始市场样本。
## 真实分钟回放
- 同一冻结请求、信号及bundle2025-01-03至2025-01-06,分钟13:07,初始10,000,000,滑点0.002,佣金万三/最低5,分钟25%量约束不改。
- 原基准 `btr_1789074235759_2081201_1`
- 新运行 `btr_1789093974375_2601124_0`
- 均21成交、11个最终持仓,最终资产9,968,551.588547;订单、成交、账户、权益、持仓和风险审计六项canonical完全一致。
- 总SHA `a1aa004f544b34eae0ade41e849a0fd067e39600d1c4ad1a127f5a3d6a79be11`
- 服务端3.490秒,客户端提交/读取/轮询16.927秒。未采集客户端各子段,不能把差值归因到某个具体服务,也不与原报告“提交后轮询耗时”混比。缓存条件的短样本不能外推冷态或多年性能。
## 发布与状态
通过官方Backtest和Trading installer构建和发布,没有调用Source/因子重启入口。Backtest service源码仍`75202cc3b876daf99d0d2dffb988ca456c34aabf`并重新链接上述engine。运行二进制SHA与清单一致。
本轮发布前10:27已观测到3Paper/0Live,重复读取确认;这不同于上一轮的3Paper/1Live,不是本次发布删除。本轮没有新建、恢复或删除实例。发布后仍3Paper/0Live,完整配置/状态摘要与本轮发布前相同。
Source主PID2267019和因子主PID2178403、NRestarts不变。发布后样本Paper9行、Live11行无WARN/ERRORRuntime0行不能视为实际执行成功;行情`/readyz`仍503THS -4302配额问题未恢复。
## 未完成
next-open全天量容量和动态滑点使用全天high/low/volume的问题没有被本次修复覆盖,仍按P0时点问题处理。新的执行观察规格位于`/Users/boris/WorkSpace/docs/fidc/execution-observation-prd-20260911.md`,只是后续实现规格,不是已部署能力。禁止静默改用昨量、自动关闭风控、修改旧结果或把后续一分钟量回填到开盘。
自然Paper/Live还需要合格模型、正式审批和真实可用行情;不开放2026封存,不替研究模型审批。当前实盘列表为空,不自行补建。
## 证据
`/srv/fidc/canonical/run/research/execution-leg-risk-20260911/`
- `engine-focused.log``engine-full.log``trading-full.log``backtest-full.log`
- `minute-replay/request.json``submission.json``result.json``comparison.json`
- `deploy-before.json``deploy-after.json``running-binary-verification.json``post-deploy-log-audit.json`
- 官方部署日志、研究审计脚本与执行观察设计稿。不改旧证据目录和WFT V18制品。
@@ -0,0 +1,87 @@
# 执行价风控与共享信号账户隔离验收
## 结论
本次修复已通过测试并发布 177。只证明一元股请求阶段价格修复、同一共享信号的账户隔离和既有真实样本结果不变;完整生产闭环尚未完成。next-open 全天容量、动态滑点的日内可见性及逐成交腿风控仍是未关闭项,不能称为全部成交无未来信息。
## 修复
- `risk_control.rs` 的 Buy 一元股规则改用本次 `check_price`,不再读取日线 `is_one_yuan` 或当天更早的 `day_open`。无效价格拒绝,其他缺失风险事实仍拒绝;显式 Selection 规则保留。
- Trading 共用 `risk.rs` 的 Paper/Live 订单前检查使用新鲜 `last_price`,不再被日线标记或开盘价覆盖。选股阶段的开关和日线标记单独处理。
- 缺执行价格继续输出具体 `missing_execution_price field=open`,保留 `historical_price_fallback=false`;停牌等权威状态仍优先,不因新通用校验丢失根因。
- 没有修改共享信号内容、模型、账号权限、运行配置、Source Lake、研究 checkpoint 或既有回测数据。
## 账户隔离组合
隔离共享核心测试使用同一份经过原生校验的 `fidc.signal-book/v2`,信号只表达保留 50% 持仓。当前价 10、止损 10%、止盈 20%;每个账户独立计算实际订单。
| 原数量 | 买入价 | 买入费用总额 | 预期剩余 | 结果 |
| ---: | ---: | ---: | ---: | --- |
| 1,000 | 8.00 | 0 | 0 | 止盈优先于半仓目标 |
| 1,000 | 10.00 | 0 | 500 | 按本账户数量减半 |
| 3,000 | 10.00 | 0 | 1,500 | 不共用其他账户数量 |
| 1,000 | 12.00 | 0 | 0 | 止损 |
| 1,000 | 11.11 | 0 | 500 | 尚未跨过止损阈值 |
| 1,000 | 11.11 | 2.00 | 0 | 含费用成本跨过止损阈值 |
六种账户卖出后,当天再消费同一买入目标均不得买回;独立未卖出账户可正常买入。同一信号版本不变,策略规划不预先修改持仓。这些是隔离合成账户测试,不是券商委托/成交证据。
## 回归与真实回放
- Engine:656 通过,8 个专用测试忽略。
- Trading 工作区:537 通过,9 个专用测试忽略。
- Backtest Runner370 通过、3 忽略;API:99 通过、1 忽略。
- 一元股专项覆盖真实执行价为 0.9/1.0/1.2、旧标记与新价格相反、缺失其他风险事实、NaN/无效价及开关独立性。
实际 HTTP 回测使用原始冻结请求、信号和 bundle,未复制结果:
- 原基准:`btr_req_260d0f3179d40fda5c918d48eba0a239bd335406c82a1cbe`
- 新运行:`btr_1789091571964_2429476_0`
- 区间:2025-02-05 至 2025-02-10;初始资金 10,000,000;目标 10 仓;next-open;滑点 0.002;佣金万三、最低 5。
- 两次均 28 成交,最终资产 10,089,448.918844,收益 0.89448918844%。
- 订单、成交、账户事件、权益、持仓、风险审计六项摘要相同。
- Canonical SHA256`befa50b3ec5b94adafede459903db7e2542797cf0eefe2de32900afc83ca1481`
- 服务端 3.954 秒,客户端含轮询 6.072 秒。此短区间已有缓存样本不能代表全市场冷态或多年性能。
## 发布
- Engine `d3c36e947894fd220b62ecd6fbfe02473f70bd2c`tag `v2026.9.11`
- Trading `dfcec36bd1c0f92e73cd30073540eb39dbd02835`tag `v2026.9.11`
- Backtest service `75202cc3b876daf99d0d2dffb988ca456c34aabf`,重新链接上述引擎。
- 只使用官方 installer,以 Boris 构建和运行。发布后 Backtest/Runtime/Paper/Live 的进程和 HTTP `/healthz` 正常,运行二进制核对独立清单,不仅检查源码 HEAD。实时行情 `/readyz` 仍为 503,原因如下,不能宣称自然交易正常。
- 原 3 Paper / 1 Live 配置和状态摘要前后相同;本次投影 Paper 为 `52a909117fe8f01ae35a327bd86310e2583d291609bba6596dc0f49a2b10559c`Live 为 `aaec1e9dbc984012e9fe677e54db1efb860edd59d5840d1dc87c4b230b26bac6`。仅与本次相同投影的发布前数据对比,不与此前其他字段投影混比。
- Source PID 2267019、因子主进程 PID 2178403、NRestarts 均不变。本轮未调用 Source/因子重启入口;不能由主 PID 不变推断全部因子子任务已经验收。
## 未关闭问题
### 实时行情配额
发布后文件日志审查发现 THS `-4302`:本周行情用量超过 1.5 亿。受保护的行情源目录只返回 `ths_realtime`enabled=true、ready=false;没有已配置可用的授权备用源。行情 `/readyz` 返回503、snapshot_count=0,实盘日志反复记录实际执行日2026-09-11请求150证券、收到0新鲜行情,因此 next-open 规划失败。
所查尾部8,000行日志中,配额告警最早已出现在01:30:12 UTC(上海09:30),早于本轮09:58的Trading发布。不能把该故障归因于本次一元股代码或用重启解决。不得拿昨日收盘、Source历史数据或手工报价代替实时价格;恢复账户配额或配置正式授权的可用行情源后,才能继续自然交易验收。
Paper的3条WARN为启动重建的PG读取,分别约1.015/1.122/1.460秒;本轮未见ERROR,但这只是采样范围,不能称全部日志无异常。证据:`realtime-quota-timeline.json``realtime-provider-readiness.json``post-deploy-file-log-audit.json`。Runtime无新采样日志不等于实际调度通过。
### 执行容量与校准
独立依赖探针确认:保持 next-open 订单和开盘价不变,仅修改执行日后来形成的全天量,成交量从 100 变为 1,000;仅修改全天 high/low,动态滑点成交价从 10.305 变为 11.000。探针是合成输入,不冒充市场证据。
详见 `/Users/boris/WorkSpace/docs/fidc/execution-time-capacity-coordination-20260911.md`。下一步须分离实测执行时点容量和声明的容量估计、冻结校准数据时钟、覆盖挂单逐成交腿;不能偷偷改为昨日成交量、关闭限制或使用未来一分钟量。V18 研究只允许新不可变后继评估,不能改现有结果。
自然 Paper 观察与正式 Live 仍需真实合格版本和正式审批。当前研究控制模型仅 23 个验证日,2026 留出期继续封存;不得为演示闭环降低门槛、伪造 observed、代替审批或手工发证券订单。
### 并发代码合并
报告推送时远端新增 `33924b1/f2e228e` 的策略自动交易保护。已保留并合并至main `f7d16fb`,177源码同步,组合引擎回归666通过、8忽略。该合并后的新保护尚未由本任务部署,线上仍使用本报告列出的d3c36e9/dfcec36清单;不能把源码同步当作发布或把对方功能归为本次已完成的自然交易验收。
随后 Trading main 新增 `4ff7ee9aeefa2e1013098212dc75b4969499a1a6`,本机与177均已正常快进同步,合并组合工作区545通过、10忽略。此为并发功能合并后的源码测试,同样不改变本次发布清单;不重复部署另一个任务尚在验收的完整交易保护功能。
## 证据
177 根目录:`/srv/fidc/canonical/run/research/execution-risk-signal-audit-20260911/`
- `engine-full-tests-v2.log``trading-full-tests.log``backtest-full-tests.log`
- `deploy-before.json``deploy-after.json``running-binary-verification.json`
- `same-signal-backtest/request.json``submission.json``result.json``comparison.json`
- `execution-time-dependency-probe.json` SHA256`feeb69275b8ad537f16e4c119cc7e59dd3a15773334fb591e27d7afdf34311d3`
- 官方部署日志与独立探针源码保存在相同证据根,不写入交易数据库或修改原始行情。
@@ -0,0 +1,109 @@
# 表达式上下文性能验收
## 范围
本轮优化 FIDC 引擎的逐股票表达式上下文,不修改策略、因子值、窗口、时间可见性、
选股/订单规则、费用、成交价、风控或公司行为。Source 保持 `d5b682c6d097`
研究和信号保持暂停。其他用户任务只读观察,不更改其进程、亲和性或配置。
已完成编译、回归、正式回放与177发布验收,不能据此关闭整个目标。
## 重复开销
1. DataSet 已采用 `NumericFactorMap`,但 `StockExpressionState` 仍把数值因子
重建为 `BTreeMap<String, f64>`,增加树节点和字符串分配。
2. 每日可用因子名集合先为每个证券复制名称,再由集合丢弃重复名称。
候选在表达式上下文延续紧凑数值存储。每日名称仍按当日真实字段生成完整集合,
仅改为先对借用名称去重,再为唯一名称分配字符串;文本因子同理。
没有用全局/未来日期目录替代当日字段,没有缓存选股结果或账户状态。
Rhai `factors[...]`、缺失、NaN、负零、别名、覆盖顺序与已完成交易日可见性保持原行为。
## CPU 计时
Runner 新增以下运行计时,HTTP benchmark 同样保留:
- `engineTaskWallSeconds`:实际引擎同步任务的墙钟耗时,包含其同步数据读取等待。
- `engineThreadCpuSeconds`Linux `CLOCK_THREAD_CPUTIME_ID` 实测的引擎调用线程CPU时间。
不包含其他并行线程、I/O等待或未被调度的时间;不可当作整个进程总CPU时间。
读取不可用、跨线程或时钟异常保持null,不填0。
- `engineTaskCount`:实际执行引擎的次数,包含原有修复循环的重放。
这些是 `engineSeconds` 的子指标,禁止再次加到总耗时。正常耗时与诊断运行分开保存。
计时不改写历史制品,旧记录缺少该指标时保持未知。
## 验收设置
- 固定引擎基线 `fe7243b`;候选为177的 `07b7b18`,对应本地 `df1862e`
- 两边使用同一计时版service `a9df11a``b5d22ff`仅补充benchmark字段读取。
- 2021-08-23至2025-11-17、初始1000万、原冻结runtime与策略,1025个执行交易日。
- 保留原 `session_capacity_audit`,不能当作实际开盘流动性验证。
- 官方benchmark入口、Boris执行、同CPU资源与Source版本、新进程、相同数据缓存副本、
新结果目录,不复用回测结果。
- 引擎780项、runner408项、API113项、脚本10项通过;9/8/3项手动或外部环境用例分别忽略。
- 专项延伸验证紧凑因子的克隆、Rhai映射暴露、缺失、NaN及负零;CPU计时验证睡眠和跨线程边界。
证据根:`/srv/fidc/canonical/run/research/engine-context-20260913`
## 独立进程对照
| 次序 | 样本 | 完整墙钟 | Source校验 | 数据准备 | 引擎墙钟 | 引擎线程CPU |
|---|---|---:|---:|---:|---:|---:|
| 1 | control-1 | 31.234s | 11.202s | 8.393s | 10.461s | 10.458s |
| 2 | candidate-1 | 17.002s | 0.004s | 8.404s | 7.406s | 7.404s |
| 3 | candidate-2 | 18.203s | 0.003s | 8.371s | 7.411s | 7.408s |
| 4 | control-2 | 22.983s | 0.004s | 8.323s | 13.401s | 13.398s |
| 5 | control-3 | 30.714s | 0.005s | 15.318s | 13.999s | 13.990s |
| 6 | candidate-3 | 25.336s | 0.005s | 13.524s | 10.471s | 10.468s |
首个基线的Source校验等待原样保留,不事后改称预热,不把11.202秒归因于引擎改动。
后段样本出现主机负载/缓存竞争变化,数据准备也变慢,不能直接用全组平均墙钟夸大提速。
相邻低负载对照的引擎线程CPU为10.458至7.404秒,后段为13.990至10.468秒。
CPU计时与任务墙钟非常接近,证明样本主要在执行CPU工作,而不是等待HTTP;
这不代表没有SMT、内存带宽或其他用户CPU竞争。
六次均为21,393笔成交,账户、权益、委托、成交、持仓和风控canonical及结果制品完全一致。
每份63个数据缓存文件经完整SHA核对相同,没有复制或读取旧回测结果。
## HTTP 对照
| 状态 | 版本 | 运行ID | 总耗时 | 引擎耗时 |
|---|---|---|---:|---:|
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232559582_3166774_4 | 21.987s | 11.328s |
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232585690_3166774_5 | 21.684s | 11.259s |
| 复用DataSet | 原版 | btr_1789232669598_3166774_6 | 11.820s | 11.031s |
| 复用DataSet | 原版 | btr_1789232684861_3166774_7 | 11.857s | 11.067s |
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232818009_3320588_0 | 17.296s | 7.537s |
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232839269_3320588_1 | 17.413s | 7.627s |
| 复用DataSet | 新版 | btr_1789232898983_3320588_2 | 8.549s | 7.738s |
| 复用DataSet | 新版 | btr_1789232910904_3320588_3 | 8.586s | 7.784s |
同状态HTTP均值:重建DataSet从21.836至17.355秒,减少约20.5%
复用DataSet从11.839至8.568秒,减少约27.6%。两种状态分开比较,
没有把8.568秒当作Source冷启动成绩。与上一轮不同时间的15/17秒样本不作直接百分比对比。
原版API没有线程CPU字段,保持null;新版本每次实际执行引擎一次,
两次重建的线程CPU为7.535/7.624秒。没有用新版本计时回填旧记录。
八次HTTP和六次独立回放的canonical及结果制品SHA全部相同,终态审计clean。
## 发布状态
177通过官方安装器发布 engine `07b7b181b60138c6ef1c965543c0e3192ac65903`
service `b5d22ffab16f851eced3028e12fa02627ee4c399`
运行身份 `fdd8652a47a5935be4d891beb3b8b0f3e19a468be166a902a2a97b85a9c9e01e`
- API SHA`bf22f58946c3fa495161eb381a400d4e28d7c8d327ee46f5645d83a8308117cf`
- Runner SHA`7b3849cd8af33d650db242add80c49cfdd32e8cc8686a614da7b3b4016ce2a60`
- 生产在用构建根:`/srv/fidc/canonical/build/engine-context-candidate-20260913`,禁止清理。
- 原生因子能力目录发布前后字节相同,SHA为
`cec37331a476bc39bdea32c308581b8ac2f86d005d8dd4cc7ba228c5d9dc9a2e`
- API PID3320588Boris、active、NRestarts=0Source仍为PID1700096/d5,研究未恢复。
[完整结构化验收证据](evidence/expression-context-performance-20260913.json)
SHA256 `f526950e018354c1305922beebf4063ae3823004f8c5ab20510a452f98b7b7ea`
## 边界
本轮真实长区间案例含一个原生扩展因子,动态映射、缺失及多字段语义另由引擎回归覆盖;
不宣称所有策略都具有相同比例提速。Source冷路径仍受独立冻结约束,
信号闭环和全部策略/分钟区间/财务PIT不在本轮通过范围内。
@@ -0,0 +1,75 @@
# 特征日行情缺口与跨日回退
## 问题
审查发现股票表达式上下文的三个位置把缺少的特征日行情回退到执行/当前市场日:
两个 `StockStateSnapshotSource::feature_market` 实现,以及最终构建器的 `unwrap_or(market)`
`factor_date` 早于 `date` 时,这会把后来日期的OHLCV用于原本指定的历史特征日。
这是错误日期代用,并具有前视风险;不据此推断所有历史回测都已触发此路径。
真实代码的合成缺口回归已复现:2025-04-03因子快照存在但行情缺失,
2025-04-07行情存在,旧版返回close=20.0、volume=45600、open=19.0
而非报告4月3日行情缺失。此回归使用合成数据验证代码路径,不是行情数据造假或真实市场收益样本。
## 修改
- 两个行情读取入口只返回指定特征日期的快照,删除向执行日的回退。
- 构建器缺少快照时返回 `MissingSnapshot { kind: "feature_market", date: factor_date, symbol }`
- 保持原市场、候选和因子缺失错误优先级;失败状态不写入股票上下文缓存。
- 同日上下文继续使用同日快照;正常跨日上下文仍将历史OHLCV与执行报价分开。
- 不调整价格、因子、窗口、风险、手续费、滑点、成交量或原始数据,不引入替代缓存。
新增回归覆盖索引读取、每日视图读取、错误缓存边界和同日合法输入。
原next-open涨跌停测试只有前一日因子、没有对应行情,原先隐式依赖了该回退。
已补充两只证券各自明确的历史行情,并断言历史价1.80与决策日价2.20分开;
不放宽时点规则,也不改真实数据。
## 当前状态
177的红色回归已确认为行为失败;最初缺少错误枚举限定名的编译失败另存,不作为复现证据。
修复后完整引擎783项、runner410项、API113项通过,分别9/8/3项既有外部或手动测试忽略。
已通过官方入口发布到回测服务;Paper/Live/Strategy Runtime没有在本轮重建或重启,
不能将共享源码修复等同于全部消费者已经部署。
## 真实回放
固定原策略、2021-08-23至2025-11-17、初始1000万及原冻结bundle。
保留历史`session_capacity_audit`,不能当作开盘容量验收。
全部运行重新执行引擎,Source/磁盘数据缓存保持,不缓存回测结果。
| 状态 | 版本 | 运行ID | 总耗时 | 数据准备 | 引擎 |
|---|---|---|---:|---:|---:|
| 清DataSet内存 | 原版 | btr_1789251904666_3596554_4 | 17.689s | 8.664s | 8.141s |
| 清DataSet内存 | 原版 | btr_1789251925997_3596554_5 | 17.082s | 8.333s | 7.947s |
| 清DataSet内存 | 修复版 | btr_1789252042334_3735010_0 | 15.668s | 7.247s | 7.608s |
| 清DataSet内存 | 修复版 | btr_1789252061568_3735010_1 | 15.717s | 6.949s | 7.985s |
| DataSet复用 | 修复版 | btr_1789252206579_3735010_2 | 8.790s | 0.006s | 7.988s |
| DataSet复用 | 修复版 | btr_1789252217644_3735010_3 | 9.892s | 0.006s | 7.880s |
六次均21,393笔成交,账户、权益、委托、成交、持仓、风控canonical及完整制品SHA一致,
终态clean,每次引擎执行次数为1。真实完整数据没有触发新增缺失错误。
最后一次包含1.233秒Source合同验证,不能把DataSet复用等同于Source无等待。
本轮未观察到该样本的性能回退,但这是正确性修复;主机负载及数据读取也有波动,
不将17秒至15秒归因于普遍算法提速,更不外推所有策略。
canonical`3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7`
结果制品:`1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9`
## 发布证据
177 engine `e3b39295787c4fd896753d633e457deddf9f1232`service `106a89d8bb74af494cdf84d9d3af5ec4bcb249cd`
- API`86f0a0385410db8ab308edf892f4ad6376c0a706c0ebbac0f397a23539d782c0`
- Runner`aebdd37ad30ee73f11a9ffd206ad8c978ced19a257cb51849881b0e5bdce36ee`
- 运行身份:`d196bd4740b1b506c42515a689ae155a21e37b8092956b99a298b8d7934e53c7`
- 在用构建根:`/srv/fidc/canonical/build/feature-date-candidate-20260913`,禁止清理。
- PID3735010、Boris、active、NRestarts=0;收据时cgroup约7.91GiB,峰值约9.29GiB。
- 原生目录字节SHA仍为`cec37331a476bc39bdea32c308581b8ac2f86d005d8dd4cc7ba228c5d9dc9a2e`
- Source保持d5/PID1700096,研究和信号未恢复,没有向券商提交订单。
证据根 `/srv/fidc/canonical/run/research/feature-date-integrity-20260913`
[结构化证据](evidence/feature-date-market-integrity-20260913.json)
SHA256 `56e70172916c45060106aca3eb006984735a3f85e6b13d2c325c409e83b8962b`
更多策略、真实缺口数据审计、Paper/Live消费者发布及完整财务PIT仍未完成。
Source目录缓存的隔离后继验证单独见Alpha Factory的`docs/native-condition-transport-20260913.md`
不能把本轮回测发布当作Source冻结解除。
@@ -0,0 +1,73 @@
# FIDC执行合同与开源框架审查
状态:源码审查和部分通用修复已完成。尚未完成多策略长区间A/B;不能据此宣称某个框架性能最优或FIDC生产闭环已通过。
## 固定版本
源码保存在`/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks`,未执行第三方项目的策略或交易程序。
| 框架 | 审查版本 | 主要参考位置 |
|---|---|---|
| NautilusTrader | 6f48cd8e713b72b878626e9a9757c6e4a026b708 | [报价类型](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/model/src/data/quote.rs:51)、[归并](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/nautilus_trader/crates/backtest/src/node.rs:442) |
| LEAN | 8ee075a39918f2df6fe9e0a5944e366fb60d10dc | [股票撮合](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/Lean/Common/Orders/Fills/EquityFillModel.cs:482) |
| Zipline Reloaded | 943010b9da848e317fc520de87edade2b884d329 | [滑点与容量](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/zipline-reloaded/src/zipline/finance/slippage.py:464) |
| Backtrader | b853d7c90b6721476eb5a5ea3135224e33db1f14 | [Broker](/Volumes/SystemSSD/WorkSpace/fidc-reference-frameworks/backtrader/backtrader/brokers/bbroker.py:228) |
## 可采用的设计
### 事件与时钟
Nautilus把Quote、Trade、Bar区分为不同类型,保存事件与接收/初始化时钟;数据归并按`ts_init`排序,分块保留同一时间戳的完整组。这适合FIDC后续有界分钟流:Source声明观测类型、覆盖区间和可见时间,执行端持有独立账户状态,而非共享一个同时装着日终与盘中字段的可变字典。
FIDC本轮实际发现:以09:32:58行情给09:33订单定价时,成交时间也写成09:32:58。已修为实际成交不得早于订单执行时钟;价格仍引用原行情,容量扣减也继续绑定原行情,不能通过换一个订单时钟重新获得额度。
### 开盘和陈旧行情
LEAN的MarketOnOpenFill单独处理正式开盘成交标志、bar开始/结束时间和订单时间;不是任意取一个日线字段即可成交。但其默认模型也有整体成交和数据回退假设,不能直接当作真实市场流动性保证。
FIDC已统一预估与撮合的盘中价格选择。己方价、对手价和最近成交价各用自己的字段;缺失/无效时不改用另一种价,也不越过最新坏行情寻找更旧的可用价格。
### 成交量与滑点
Zipline按股票和bar累计已使用量,历史冲击窗口排除当前未完成交易日。FIDC应复用不可变历史校准,但每个账户自己的成交消耗必须独立;不能按订单次数重复分配同一个bar的额度。它在历史不足时的回退不是FIDC的默认准入策略。
FIDC新增`ParticipationRate`:按冻结JSON小数语义精确向下取整,避免浮点乘法和窄整数转换误差。例如58%乘50股为29,不应因二进制误差成为28;巨大市场量也不能在转u32时截断成另一个额度。比例解析在配置阶段完成,不放入逐成交腿热循环。
Backtrader把volume filler、滑点开关、cheat-on-open/close等模型选择显式区分。值得采用的是明确暴露假设;不照搬不带成交量限制的默认值,也不为了收益对齐开启能改变可见时间的行为。
## 已落地通用修复
- 精确参与率计算用于Broker和策略预估,同一观测的已消耗量跨订单共享、跨账户隔离。
- 实际执行时钟与参考行情时钟分开,修复成交早于下单的问题。
- 缺失的执行价不由其他价格或更旧行情代替;删除重复价格选择及不可达深度分支。
- Runner拒绝空身份、错日期、晚于请求时钟的行情、非有限价格、缺失或非整数数量。
- 多查询引用同一观测只有完全一致才合并;同一目标查询的源重复行和跨查询冲突均报错,不采用第一条。
- 目录缓存使用Linux变更通知,不能只凭mtime/目录大小判断没有新文件;通知不可用时重新扫描。事件溢出、目录替换、重命名、删除均失效重建。
这些改变不按策略ID、固定选股条件或10:17/10:18等时间分支。
## 仍未关闭的问题
1. **日线全天量仍进入早晨数量计算**Broker和projection两条路径都需接入容量模式。`VolumeCapacityMode``SessionCapacityAudit`目前只是已测试基础类型,未完成配置、撮合、日终结果和页面的完整接线。不得称P0已修。
2. **Source可能掩盖缺失**:当前目标分钟序列会先剔除无效close,再寻找旧的有效值;部分缺失volume/amount置0;备用Arrow包装器还会用请求时钟替代缺失行情时间。必须清理这些行为,并按数据/市场缺席合同分类。
3. **TWAP早期配额可能依赖未来可成交记录数**:当前实现使用筛选后的`eligible_quotes.len()`分配数量。应按冻结时间表切片,后续缺量只能影响后续执行,不改早期分配。
4. **集合竞价**:实时数据与日线/分钟模型必须区分委托进入竞价、等待撮合和实际成交,不能用竞价中间的零量判定股票停牌,不能提前使用最终清算价。
5. **数据种类/单位**:源观测类型与价格模型能力要明确;分钟OHLC不能冒充真实L1深度。盘口单位、名义bar区间和可见时间仍须逐源校验。
## 下一阶段实施合同
- 普通风险开关和参与率仍是同一个策略三端共享配置。历史回测的容量假设单独冻结,不能用研究审核模式绕过Paper/Live临单风控。
- 有真实可见观测时使用`execution_observation``completed_bar`须验证bar结束和可见时间,不靠日线日期猜15:00。
- 只有日线总量而没有开盘容量时,严格模式须在提交预检失败;用户可明确选择`session_capacity_audit`做研究。日终只判定容量验收,不回写早晨的数量、现金、持仓或净值。
- 不能自动关闭参与率限制,也不能悄悄替换为昨日量。
- Source、Runner、Engine、研究handoff、Strategy AI和UI成套发布;持有冻结合同的旧任务不改写。当前研究维护方尚未授权共享服务切换。
## 验证与基准
Engine当前680项通过、8忽略;Runner375项通过,真实Source的8个JSON/Arrow行情点通过严格解析。小样本中的14:59零量在serving Parquet中确实存在,15:00有成交;这是源事实核对,不是完整集合竞价逻辑验收。历史股票收盘竞价时段应按适用规则处理,不能将后来的规则回填到旧日期。[深交所2023年交易机制说明](https://investor.szse.cn/institute/rules/t20230629_601434.html)
上交所2026版规则于2026-07-06生效,且通知明确存在暂缓实施条文。后续交易时段/容量合同必须同时核对规则版本和实施范围。[上交所发布通知](https://www.sse.com.cn/lawandrules/sselawsrules2025/stocks/exchange/c/c_20260424_10816482.shtml)
下一组A/B应覆盖动态Top-N日线、静态组合、逐分钟策略、TWAP/VWAP及共享模型信号,分别检查同条件重复、时间变更、参数变更、短区间和五年区间。先关闭时点错误,再测准备/引擎/持久化/端到端耗时、峰值RSS、缓存代际与逐笔差异。不得用微型元数据读取速度外推完整回测性能。
本轮证据根:`/srv/fidc/canonical/run/research/historical-slippage-20260911/`。服务均未由本任务重启,没有提交回测任务或证券订单。
+44
View File
@@ -0,0 +1,44 @@
# 日内时钟与手工回放前置问题
2026-09-14。本轮时钟与工作中算法单候选已完成本机回归,尚未部署。177仍运行Engine c98bcc3 / Service e81bf47;完整手工影子回放尚未实现。
## 已复现的精确反例
`engine::tests::minute_observer_never_sees_a_later_fill_from_a_coarse_phase`使用实际BacktestEngine/BrokerSimulator测试入口、同一证券及合法测试日行情。开盘竞价回调生成100股限价10.0的委托,全天存在09:30、10:00、10:15、13:00、13:01报价,后续分钟回调读取真实模拟账本。
- CurrentBarClose/09:30窗口:10:15成交;10:00观察为0股,通过。
- NextBarOpen/一天信号滞后/09:30窗口:10:15成交;10:00观察为0股,通过。
- CurrentBarClose/13:00窗口:实际FillEvent时间13:00、数量100,但09:30、10:00、10:15回调均已观察到100股,失败。完整观察序列为`[(09:30,100),(10:00,100),(10:15,100),(13:00,100),(13:01,100)]`,不是仅日志显示错误。
根因路径是粗粒度auction/on_day阶段调用broker时使用未来的全局intraday_execution_start_time,先将13:00成交写进PortfolioState,随后引擎才从09:30开始遍历分钟事件。正常09:30路径已有边界,不能因为一次测试通过就断言所有时点安全,也不能把所有粗粒度调用一概认定有问题。
首次盘前调度夹具没有产生订单,因此不作为时钟证据;改用明确返回委托的open_auction回调完成上述复现。盘前on_scheduled普通委托是否被忽略应另行核对其正式合同,不能当空成功。
## 必须按真实执行时序修复
不能删掉早间回调或给显示持仓做遮掩。需要使已生成的未来执行意图、待执行批次、订单回报、策略回调、手工意图及实际投影按执行时钟前进;保留独立信号日与数据可见性。不能仅把新订单延迟却让依赖持仓的后续策略回调仍提前计算。
需覆盖当前/下一开盘、显式时间和默认收盘、限价/市价/算法单、部分成交及取消、股票池卖后续买、跨日/T+1、0%人工覆盖和恢复。已有真实回放与六类Canonical必须按各自合同核对,不能用收益接近或单个对照替代。
上述原失败回归已保留并修复:晚窗口执行与日度回调进入真实日内时钟,不再先写未来持仓。独立信号日及滞后执行的数据合同保留。仅有日内观察或待处理开盘目标时,未显式设时间的日线收盘回调才延至15:00;物理时钟与委托提交时点分离,不能把普通日线收盘撮合误变为15:05盘后委托。
## 本轮新增证据
- TWAP旧路径在13:00一次消费13:01、13:05报价,导致13:00观察到900股;现在逐时钟消费,同一父订单保留原始总量、已成交量、剩余金额、最低佣金余额和期限,不重新生成订单。
- 分片时钟继续使用原算法窗口决定TWAP比例及深度约束,不把每个瞬时时钟当作新的不限量算法单;VWAP全局撮合也延续同一工作中订单。
- 算法定量使用提交时已经可见的报价。改变当日后续收盘价不改变早先订单数量;真正缺报价明确失败,不读未来报价或日线价替代。
- 当天已完成委托/成交记录及时移动到运行历史,后续分钟、日度与定时策略回调能读取;不逐分钟复制全部历史。
- ETF下一开盘回退保留真实日线开盘价、3700股及原信号日,入账从早间预处理移到09:30事件;反例09:15原来可见3700股,修复后为009:31为3700且仅一笔ETF成交。不合成ETF分钟线。
- 工作中算法单只预留真实可用现金;两个各10000元意图、15000元账户按顺序预留10000/5000,后续分别成交900/500股,先到订单不被后到订单的超额预留饿死。
- 已验证部分成交后撤单、无末尾报价到期、T+1、IOC终止及原合同拒绝算法FOK/GTC;未新增不支持的有效期。
- 同一TWAP与同步参考逐笔数量/价格/时间/订单ID/各项费用完全一致;VWAP逐时钟成交金额与总费用一致。最低佣金只扣一次,成交资金不超过冻结预算。
本机Core 822项通过、9项原有ignoreTrading工作区613项通过(外部PG等原有ignore未当通过);最新main的Runner446/API119项通过。同期main风控候选d2aa16a已保留并组合回归。本机测试不代替177不可变构建与真实数据回放。
## 发布前置与剩余边界
177于03:46只读核对仍为3Paper/0Live,原配置与旧委托摘要不变,Source d5b682c6/PID1700096未变,真实路由disabled。SSD剩约1.6GiB;官方编译缓存清理计划无候选,未删除任何数据或构建。官方复用审计确认target-backtest无运行引用,后续只允许带1GiB余量保护的本次构建,不能覆盖在用发布根。
还需完成Linux精确提交构建、固定历史合同回放及配套发布;通用process-event回调的完整时间/订单观察、盘前调度普通意图、显式手工委托/撤单回放和所有剩余参数矩阵继续跟踪,不以当前核心测试声明完整Goal完成。当前不解除手工影子拒绝门禁,不修改既有任务配置、Source冻结和研究/信号暂停。
Live取消请求另有待核对项:`CancelRequested`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。
+15
View File
@@ -0,0 +1,15 @@
# 生命周期、价格缺失与历史状态
证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。
执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。
513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。
## 真实边界回放补充
177 回测 `btr_1789041425783_797911_1`920038.BJ2026-08-04 至 08-07。真实上市日08-05,原结果只保留08-05至08-07三个净值点。原因是准备面同时加载基准000300.SH,基准不是交易候选但参与了“全部证券生命周期外”的判定。现在只排除已声明且没有交易候选记录的基准,不按代码或名称猜测指数,也不把真实候选排除;补充真实准备结构的回归后,4日现金区间完整保留。
该草稿沿用源池 `rejectBjseSelection=false``rejectBjseBuy=true`,所以选中北交所但不下单符合其买入政策;原规划阶段没有记录拒绝原因则是审计缺项。新增 `scope=buy, stage=buy_planning` 审计,不伪造订单ID,不把买入否决改写成选股排除。测试验证禁止时无订单且有bjse原因,放开买入政策时正常生成意图。最新核心514项通过、6项原有忽略。
+13
View File
@@ -0,0 +1,13 @@
# 仓位事件执行合同
2026-09-13。`runtimeExpressions.risk.positionExposureEvents` 使用带eventId、严格唯一sequence、UTC有效时点的事件;必须明确指定set、scale或restore。缺失动作、重复身份、非法比例和无时区日期均拒绝。
- scale用于人工比例乘数:普通轮动仍先计算策略自身仓位,0%指数择时不会被人工100%覆盖。显式权益买入和目标类委托,以及SignalBook产生的意图,同样按比例处理;不修改原SignalBook。
- 卖出/减仓增量、零目标清仓、取消、订阅、现金流和价格不被缩量。对已有买单增加数量只缩放增加部分;无法确定被改单身份时拒绝。期货等未定义类型不静默转换。
- set用于股票池投入比例等明确绝对目标;restore恢复原策略/池规则,不转换成100%。旧日期级positionExposureSchedule保留原粒度,新的恢复事件不再回落到旧人工值。
- 比例按实际执行时点读取;股票池不再用信号日读取覆盖值。原引擎首信号日现金基线和next-open调度合同不改变。
- 不改变OHLCV、费用、价格精度、证券生命周期或成交量容量合同。
验证覆盖同日多次调整、未来事件隔离、0/30/50/100%、20%原策略恢复、显式委托与现金流、以及原始引擎跨周末的股票池回放:1月2日信号在1月5日执行,1月5日覆盖在该日生效,1月6日恢复20%而不是100%。测试行情明确是隔离夹具,不代表真实历史或券商成交验收。
交易侧用不可变操作审计提供事件,保留运行任务/账户绑定和原始请求。此模块不自己下单或创建新的回测,不读取用户资金账户。未完成的独立人工调仓命令与逐笔人工交易影子回放仍需另行验收,不能据时间线通过声明所有调仓路径完成。
@@ -0,0 +1,126 @@
# Selection Risk Plan Performance
## Status
Candidate tested, not deployed. The change removes selection calls that have
no possible effect under the current frozen policy. It does not disable any
configured rule, execution-day check or strategy expression. Engine time falls
slightly in the measured cases; this is not the solution to the main remaining
data construction cost and is not a general whole-backtest speedup claim.
The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md)
remains open. This work does not remove that test or its evidence, change the
execution clock, or turn day-level parity into full framework acceptance.
The published service stays at e81bf47/c98bcc3. Source d5b682c6 remains frozen;
research and signal work stay paused. No trading operation was submitted.
## Evidence Leading to the Change
The official HTTP diagnostic replay btr_1789322878865_2871869_0 preserved the
original canonical and result-store SHA. Ten bounded Boris-only GDB snapshots
showed source inventory, PreparedDayBuilder, factor normalization and price
series construction, followed by repeated selection risk calls. GDB pauses are
not normal performance measurements and snapshot counts are not flamegraph
percentages. Source/target PID, binary SHA and CPU/thread resources stayed fixed.
The diagnostic helper now shares the existing canonical executable policy with
the saved-run profiler: it accepts both audited build roots and immutable API
release directories, but not arbitrary paths. Seven related tests passed.
## Implementation and Correctness
StaticRiskRuleConfig reports whether selection has an enabled state rule or an
enabled nonempty blacklist. The strategy computes this once before iterating
candidate symbols. If no such rule exists, the old selection function would
always return None, so that no-op call is omitted. Explicit universe conditions,
market/factor checks and all buy/sell execution paths are unchanged.
The ten state flags are also shared with the existing missing-risk-state checks
to avoid maintaining three separate flag lists. Blacklist presence is kept
separate: a blacklist is not missing market-risk data. No cross-strategy cache,
strategy identifier, fixed date, trading time or account state is introduced.
Tests enumerate all 4,096 combinations of ten selection flags and blacklist
enabled/populated states. The flag list is checked against the serialized
configuration, so adding a selection field requires updating the activation
test. Further tests retain missing-state behavior and show that paused buys
and sells remain rejected when selection checks are inactive.
On 177: 805 core unit/integration tests passed (9 ignored), 448 runner tests
passed (9 ignored), 119 API tests passed (5 ignored), and 28 benchmark/profiler
tests passed. These counts do not resolve the independently recorded clock
failure, which is not part of this frozen committed test tree.
## Reproducible Shared-Input Method
Each of the twelve replays has a new process and a new private result root.
The official runner benchmark gained --shared-runtime-cache. It resolves the
explicit cache root from the declared Boris service, requires canonical private
storage, hashes existing inputs before and after, and refuses any changed or
removed original. This mode cannot invoke copied-input disposal.
All twelve runs used the same 9,257 files / 12,596,608,049 bytes. Their complete
input inventories, file identities and byte SHA values are equal. No new Arrow
or binary cache input appeared. No backtest result was reused. Hash preparation
and verification are outside the measured runner interval; this is a shared
warm-input test, not raw-disk cold IO. Unlike the earlier copied-cache method,
it does not allocate another approximately 2 GB per replay on the nearly full
SSD. Original inputs and every result remain intact.
The common execution interval is 2021-08-23 through 2025-11-17 with 10,000,000
initial cash and each case's unchanged frozen strategy/bundle. This is not five
complete execution years. CPU affinity and 8 Rayon / 16 Tokio threads match the
declared reference service; no global resource limit was increased.
## Measurements
| Case | Wall seconds | Source validation | Data preparation | Engine |
|---|---:|---:|---:|---:|
| Rotation control 1 | 30.986 | 12.244 | 8.445 | 8.790 |
| Rotation candidate 1 | 12.977 | 0.004 | 5.132 | 6.682 |
| Rotation control 2 | 13.275 | 0.209 | 5.190 | 6.739 |
| Rotation candidate 2 | 12.927 | 0.005 | 5.128 | 6.640 |
| Rotation candidate 3 | 12.926 | 0.006 | 5.113 | 6.664 |
| Rotation control 3 | 13.044 | 0.005 | 5.159 | 6.732 |
| Trend 40 control | 15.628 | 0.694 | 5.234 | 8.199 |
| Trend 40 candidate | 14.826 | 0.004 | 5.276 | 8.087 |
| Pullback 40 control | 13.776 | 0.005 | 4.893 | 7.374 |
| Pullback 40 candidate | 13.928 | 0.003 | 5.137 | 7.239 |
| Volume momentum 80 control | 18.176 | 0.004 | 4.585 | 11.154 |
| Volume momentum 80 candidate | 18.627 | 0.005 | 5.200 | 11.013 |
Rotation engine medians are 6.739 versus 6.664 seconds, approximately 1.1%.
The other paired engine reductions are approximately 1.4%, 1.8% and 1.3%.
These are small CPU-path improvements. Pullback and volume total latency did
not improve because their preparation times were higher. The first control's
Source wait and unexplained slower construction are recorded, not attributed
to this code or discarded to manufacture a large speedup. Peak RSS stays about
6.76-6.83 GiB; there is no significant memory reduction claim.
Each case matches its independent prior baseline for all six canonical
sections and store bytes: 21,393 / 29,776 / 31,862 / 51,300 fills. Result receipts,
runtime/strategy identities, physical manifests and 3,506 fact blocks were
verified. The shared input inventory SHA is in the acceptance record. Full
unaltered receipts remain on 177; the repository stores the compact verified
summary rather than repeating the 9,257-file inventory in every document.
## Remaining Work
Prioritize direct typed-column reuse during daily snapshot and DataSet
construction; approximately five seconds of preparation remain in these warm
cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks.
Source cold-query and contract-validation latency remain separate tasks under
the Source freeze. The earlier cache-boundary candidate still needs its missing
cold/same-window acceptance, and this combined candidate has no HTTP publication
gate yet. Financial PIT, minute-clock behavior, signal lifecycle and UI factor
condition acceptance are not claimed complete.
- Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4.
- Candidate service source: 4e23c7558d8301ba697543c39d5604289bb82c53.
- Control runner SHA: b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d.
- Candidate runner SHA: 1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb.
- Candidate API SHA: 30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501.
- Evidence root: /srv/fidc/canonical/run/research/selection-risk-plan-20260914.
- HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914.
[Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json).
@@ -0,0 +1,10 @@
# 股票池候选顺序合同
新请求可显式设置 `runtimeExpressions.selection.preserveCandidateOrder=true`,同一 `candidateSymbolsByDate` 同时冻结成员和顺序。原有未设置该标志的策略保留成员过滤后自行排名的语义,不改写历史回测。
- 顺序在解析时保留,重复证券仍报错;空日期保持空,不继承旧候选。
- 不再走市值快排或套用旧 rank 方向。选股风控和股票条件仍在 Top N 前执行,被排除后从后续已冻结候选补位。
- 该标志必须绑定非空的日期映射,不允许空映射放开全市场。
- 股票池完成日线筛选的新前端请求采用 next_bar_open,日线信号日与真实执行日分离。
本轮共享内核全量回归 668 项通过(8 项显式忽略),新增顺序/旧排名方向/选股排除补位验证。该记录不是实盘成交验收,也不代表手选与自动候选混合来源完整实现。
@@ -0,0 +1,28 @@
# 股票池卖出批次与买入续执行
2026-09-13开发,2026-09-14 00:00至00:06 CST完成177配套发布,annotated tag v2026.9.13.16。Engine c98bcc3、Service aa3fe40、Trading b1d402e;不是完整股票池验收结论。
## 原问题
真实混合四证券的手选优先/自动优先回测在09-11出现600276.SH与300811.SZ买量差异。冻结信号权益均9,733,801.863803、90%预算8,760,421.67742270,前一日持仓/现金也相同。原进程日志证明卖出000333.SZ 500股仍为Pending时,买单已经根据未释放的总仓位预算被创建或取消;其后卖单实际成交,执行器不再继续尚未提交的买入阶段。不能仅因为账户还有现金就忽略仓位预算,也不能通过重新跑策略/重复补单掩盖。
确定性回归在旧实现中稳定复现:200股卖出限价未成交,实际成交回报处理后新标的仍没有持仓;无需网络或外部数据。现增加每池单一未提交执行阶段,sell_then_buy在卖单活动期间不创建买单,报告终结后沿同一冻结信号/权益/配置,根据当时真实现金、持仓和报价只执行买入腿。策略不再次调用,已经提交的委托不替换、不去重补救。
## 边界
- 分批成交等待整批活动委托终结;余量保持原order_id。买入以真实成交后资金与仓位预算重新定量,不借预计卖出款。
- 每池新意图先替换尚未提交阶段,已提交订单仍保留;同一次止盈/止损清仓的证券保留禁买事实,不能在等待后重新当作未建仓候选买回。
- 买单真实提交日/时刻与原信号日分开。next-open卖单延迟后,新买单使用执行时点真实分钟报价,不回到09:30或用日线开盘价代替缺失报价。原始挂单起点不变。
- 原窗口结束为排他边界,休市不创建买单;过期只终止未提交阶段,原券商模拟订单按原DAY/GTC时钟自然处理。交易日结束清除未提交阶段并记录原因,不跨日重用。
- 引擎即使没有策略分钟订阅,也为活动批次维护真实报价时钟,并加载待买标的;不新增策略回调。
- 未修改Source、行情/生命周期门禁、风控、原用户配置或历史结果。PreOpenCash/SamePointNet不因本补丁被强改成SellThenBuy。
## 当前测试
9项新增专项覆盖未成交卖出续买、部分成交/买单ID、窗口结束、新信号覆盖、发送前新价/日期、缺价拒绝、止盈清仓禁回买、跨日清理和不订阅分钟的完整引擎执行。全工作区803项通过、9项外部/专项忽略单列;配套Trading613通过,Runner本机432通过、9项忽略。完整引擎测试夹具需显式提供每日因子与候选,缺少两者会得到无执行日期,不能据空运行当作成功。
177独立进程对三个原请求分别执行原版和修复版,共六次原生回放;原版各自与原历史Canonical相等,原请求及数据包不变。修复后两种优先级均10成交/4持仓/权益9,706,248.648662,逐股数量、费用、时钟、逐日权益和持仓完全一致(订单ID仍按各自原顺序生成,不伪装为同一Canonical)。原24只回放51成交/21持仓/权益9,685,563.876924999,不强求保留旧54笔:09-08和09-10卖出晚于窗口,未提交买入阶段到期;09-11卖出09:31完成后继续买入。混合样本09-09与09-10同样在窗外不新建买单,09-11在09:34完成卖出后续买,已提交DAY单可在窗口后继续成交。
生产API三次验收分别为btr_req_6854471517438a896378785b96a81e4ab41f0d77f898bf37、btr_req_0d32c6e07598c16728992374f1800804ad2cd06d85f18d15、btr_req_4ae4ee17bf90bbba5ca579a79c7d4e1c410fc2d4506e5800,均与对应原生候选Canonical相同;旧结果/配置回读保持。未提交券商委托、创建交易任务或改写配置,Source冻结及研究/信号暂停保持。完整逐笔回执在177 /srv/fidc/canonical/run/research/stock-pool-sell-buy-20260913,部署回执/tmp/fidc-sell-buy-api-release-20260913.json与/tmp/fidc-sell-buy-trading-release-20260913.json。
优先级在真实资金或仓位约束不足时仍可影响分配,不能将本例结论外推所有排序。完整Goal下一项仍是手工委托影子回放、流式日期消息/摘要投影和剩余参数矩阵;不重复此已解决样本。