Compare commits

...

110 Commits

Author SHA1 Message Date
boris c8cbc5dc96 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	crates/fidc-core/src/metrics.rs
2026-08-22 18:58:44 +08:00
boris 6fba34d2e4 修正回测出入金现金流中性口径 2026-08-22 18:54:34 +08:00
boris 375b8b2df1 补充可配置过户费并纳入成交成本 2026-08-22 14:50:00 +08:00
boris b8776d7169 修正入金后的资金流收益指标 2026-08-22 14:32:52 +08:00
boris 7a1631efa3 补充策略执行频率字段 2026-08-22 10:59:38 +08:00
boris fe7e0f397f 修正无变化目标订单记录 2026-08-01 22:10:44 +08:00
boris 839ca1fa0d 预编译策略运行时辅助函数 2026-08-01 21:51:46 +08:00
boris d51d324977 复用策略表达式解析计划 2026-08-01 21:44:57 +08:00
boris 6f2c39aaf2 并行构建证券序列索引 2026-08-01 21:39:17 +08:00
boris 2a4a9d1290 构建无锁证券序列索引 2026-08-01 21:34:51 +08:00
boris 29fcd67bf8 按表达式需求构建运行作用域 2026-08-01 21:30:37 +08:00
boris faa8ac7c13 优化复权与成交量滚动窗口 2026-08-01 21:23:11 +08:00
boris 40e4c12cdd 移除策略生成默认收益门槛 2026-08-01 18:36:29 +08:00
boris 53c68250e4 区分业务选股与框架风控 2026-08-01 18:11:36 +08:00
boris 2749983267 修复策略前置表达式滚动函数执行 2026-08-01 17:14:37 +08:00
boris 2c93f4a1ed 补全调仓现金口径生成约束 2026-08-01 16:15:25 +08:00
boris 51acdf1d31 拒绝废弃的引擎配置档案字段 2026-08-01 12:47:23 +08:00
boris d21680ed4f 删除隐藏兼容模式并统一撮合风控 2026-08-01 12:37:31 +08:00
boris ca9732ecb2 修正模型排名缺失诊断 2026-07-22 09:13:42 +08:00
boris 4132793219 修正退市整理期交易状态判定 2026-07-19 08:44:11 +08:00
boris d9ce3eeb5c 修正满仓后既有目标调仓中断 2026-07-19 08:23:47 +08:00
boris 24528ecfeb 修复调仓卖出失败后的持仓槽位溢出 2026-07-19 04:51:49 +08:00
boris a77a00c70a 隔离退市持仓与模型目标状态 2026-07-18 18:20:26 +08:00
boris 117f7be9c8 修正退市持仓槽位与重复订单 2026-07-18 18:03:23 +08:00
boris bcb45077fb 修正策略生成滑点合同提示 2026-07-18 17:24:10 +08:00
boris 6c39acd54e 修正FIDC运行风控验证入口 2026-07-18 16:37:31 +08:00
boris 518aadb9fd 修正完成日触板候选判定 2026-07-18 09:04:59 +08:00
boris 0dca331950 严格使用真实上市天数过滤候选 2026-07-18 08:54:04 +08:00
boris 4c0fde7621 修正动态排名替换目标状态 2026-07-18 08:35:12 +08:00
boris 755fffda0f 修正显式目标单批次撮合顺序 2026-07-17 23:43:04 +08:00
boris af4cd25f47 修正复权序列未来数据污染 2026-07-17 23:22:39 +08:00
boris 59a500b879 修正当前复权均线预计算命中 2026-07-17 23:05:36 +08:00
boris 71b5acee30 修正持久目标组合调仓顺序 2026-07-17 22:41:03 +08:00
boris 031e4ee054 修正目标组合卖出后统一补仓 2026-07-17 22:22:14 +08:00
boris b6df63c79e 修正同批卖出资金复用 2026-07-17 21:46:23 +08:00
boris 6e8eeb984f 修正当前滚动因子日期取值 2026-07-17 16:31:47 +08:00
boris ffc9179cff 修正当前滚动因子运行语义 2026-07-17 15:47:51 +08:00
boris ef491340f6 支持运行态预计算滚动均线 2026-07-17 14:54:25 +08:00
boris 7f65fda790 修正未成交清仓意图持久化 2026-07-17 13:34:54 +08:00
boris 5918a03456 修正未成交模型持仓生命周期 2026-07-17 13:26:57 +08:00
boris 0337cc8a22 保留成交量空值滚动语义 2026-07-17 13:11:50 +08:00
boris 4d7245d8b0 修正延迟卖出意图的持仓槽位 2026-07-17 12:54:33 +08:00
boris 81ac623fca 修正延迟调仓剩余仓位预算 2026-07-17 12:43:53 +08:00
boris bdd5a41106 修正调仓后剩余买入预算 2026-07-17 12:32:25 +08:00
boris 60457389a3 支持显式止盈止损参考价口径 2026-07-17 12:07:07 +08:00
boris 8f098e4da1 修正股票日线复权滚动因子口径 2026-07-17 09:47:47 +08:00
boris a734cbeaec 修正延迟撮合选股与目标金额语义 2026-07-17 08:55:16 +08:00
boris 8f167e7de1 格式化表达式解析回归测试 2026-07-17 08:44:02 +08:00
boris 2c1a9be38e 修复嵌套三元表达式解析 2026-07-17 08:33:58 +08:00
boris 01b2ca02ff 完善生命周期持仓与当前日滚动语义 2026-07-17 07:34:59 +08:00
boris 9d47d06064 支持动态排名每日替换上限 2026-07-15 21:49:46 +08:00
boris e7d1c875fd 修正退市持仓虚假现金兑付 2026-07-15 20:48:17 +08:00
boris d63ac73903 修正目标金额零数量虚假订单 2026-07-15 19:23:41 +08:00
boris 26315e2016 修正组合回撤负向测试断言 2026-07-15 18:41:28 +08:00
boris 9f85625b83 增加组合回撤冷却风控状态机 2026-07-15 18:39:50 +08:00
boris e17c5ad3b0 新增信号日基准收盘字段 2026-07-13 15:51:24 +08:00
boris 5f5f0fcf16 补充目标仓位降仓回归测试 2026-07-12 16:44:39 +08:00
boris bacb70e327 修正下一开盘目标仓位计算 2026-07-12 14:59:12 +08:00
boris 0ea5fae69d 统一次日开盘新仓目标市值指令 2026-07-12 05:55:55 +08:00
boris 214872dfbf 修正次日开盘目标市值现金投影 2026-07-12 05:47:37 +08:00
boris 2b64fb7c7e Revert "修正次日开盘目标市值换股语义"
This reverts commit 438757ab54.
2026-07-12 05:42:00 +08:00
boris 438757ab54 修正次日开盘目标市值换股语义 2026-07-12 05:33:50 +08:00
boris 67f15f12ca 隔离次日执行价与信号日资金预算 2026-07-12 04:49:40 +08:00
boris 992d0e063c 保留次日执行目标市值指令 2026-07-12 04:41:58 +08:00
boris 428434d98d 修复次日开盘目标市值未来数据 2026-07-12 04:37:03 +08:00
boris 20b07ddd7d 移除决策日市值二次推算 2026-07-12 04:21:46 +08:00
boris c094e78bef 修正周期调仓等权资金预算 2026-07-12 01:45:33 +08:00
boris 57345e8230 修正下一交易日信号时点字段可见性 2026-07-12 01:27:50 +08:00
boris d5d67102ac 支持排名缓冲换仓策略 2026-07-12 01:05:56 +08:00
boris 30a4071ee0 对齐模型轮动目标调仓语义 2026-07-12 00:31:00 +08:00
boris 942ba84ca5 保留模型评分显式调仓日期 2026-07-11 23:50:00 +08:00
boris ab3c821e59 修复滞后执行卖出资金投影 2026-07-11 23:39:18 +08:00
boris 1953e92b7b 更新策略生成三年收益目标 2026-07-10 15:36:11 +08:00
boris 9cc625409f 统一退出信号与显式调仓语义 2026-07-10 14:41:18 +08:00
boris 558d92fe23 禁止退出信号股票当日补仓 2026-07-10 14:21:13 +08:00
boris 0aef8f9491 删除目标组合错误回补分支 2026-07-10 13:39:41 +08:00
boris e275f4632d 修正AiQuant目标权重fallback执行口径 2026-07-10 13:02:53 +08:00
boris 5166916926 修正目标组合全仓卖出失败回补语义 2026-07-10 12:40:42 +08:00
boris 56859dbe32 修正AiQuant目标组合执行风控延后语义 2026-07-10 12:01:11 +08:00
boris 1272e427a1 修正目标组合现金安全搜索 2026-07-10 11:37:12 +08:00
boris e396c895dc 修正AiQuant兼容持仓成本止损口径 2026-07-10 10:35:58 +08:00
boris f7d0889bbc 补充目标组合执行日展开测试 2026-07-10 04:21:17 +08:00
boris 9b84f3a1b9 补充目标仓位估值价回归测试 2026-07-10 04:07:06 +08:00
boris b1520fcca0 支持执行日行情价格映射表达式 2026-07-10 03:55:57 +08:00
boris bb51d91b76 修复开盘调仓估值价格口径 2026-07-10 03:41:38 +08:00
boris 2c43feec3e 兼容百分比滑点模型别名 2026-07-10 03:00:17 +08:00
boris 825de1d886 禁止目标组合调仓放大目标权重 2026-07-09 20:27:50 +08:00
boris 7397a2d69f 精简平台选股缺排名字段诊断 2026-07-08 11:14:43 +08:00
boris 7951ba67e3 修正执行日退市缺行情拒单原因 2026-07-08 11:07:30 +08:00
boris 2fcacb4313 修正弱市止盈前减仓顺序 2026-07-08 07:02:17 +08:00
boris 5e480cd69b 修正延迟卖出后止盈止损补仓槽位 2026-07-08 05:32:59 +08:00
boris bfbbac8952 修正AiQuant兼容策略退出槽位默认语义 2026-07-08 05:24:45 +08:00
boris bb04864436 增强补仓调试诊断 2026-07-08 05:20:05 +08:00
boris b87e1b4a02 修正延迟卖出后止损补仓槽位 2026-07-08 05:13:13 +08:00
boris 185ed49fe2 修正数字止损边界口径 2026-07-08 05:06:55 +08:00
boris a30face86a 修正分钟止损缺少quote误触发 2026-07-08 04:58:56 +08:00
boris 188376b75a 修正预计算rolling缺失回退 2026-07-08 04:52:18 +08:00
boris 6a98d9b0bd 释放全仓待清仓补仓槽位 2026-07-08 04:40:55 +08:00
boris a562a8e2ed 修正延迟日待清仓补仓槽位 2026-07-08 04:38:21 +08:00
boris 215c4046d1 修正满仓待清仓补仓槽位 2026-07-08 04:28:28 +08:00
boris d30c93989c 修正弱市部分止损补仓槽位 2026-07-08 04:21:15 +08:00
boris 4554f92fb4 Revert "修正普通日部分退出补仓槽位"
This reverts commit 556ed9b848.
2026-07-08 04:15:23 +08:00
boris 556ed9b848 修正普通日部分退出补仓槽位 2026-07-08 04:11:44 +08:00
boris 344e7e90c2 Revert "修正部分延迟卖出槽位计数"
This reverts commit c64bf16c8b.
2026-07-08 04:09:16 +08:00
boris c64bf16c8b 修正部分延迟卖出槽位计数 2026-07-08 04:06:34 +08:00
boris ce5ef3b77d 修正延迟卖出日止盈补仓槽位 2026-07-08 03:58:55 +08:00
boris 8f47ee3679 回退延迟卖出补仓槽位计数 2026-07-08 03:46:15 +08:00
boris f15f229a09 修正延迟卖出补仓槽位计数 2026-07-08 03:43:11 +08:00
boris da12cdddd4 Revert "修正延迟卖出后的补仓槽位"
This reverts commit 0bb47812e5.
2026-07-08 03:35:03 +08:00
boris 0bb47812e5 修正延迟卖出后的补仓槽位 2026-07-08 03:33:04 +08:00
26 changed files with 9280 additions and 1921 deletions
Generated
+52
View File
@@ -99,12 +99,43 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -117,6 +148,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"indexmap",
"rayon",
"rhai",
"serde",
"serde_json",
@@ -294,6 +326,26 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rhai"
version = "1.23.6"
+1
View File
@@ -14,6 +14,7 @@ authors = ["OpenAI Codex"]
chrono = { version = "=0.4.44", features = ["serde"] }
indexmap = { version = "=2.11.4", features = ["serde"] }
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
rayon = "=1.12.0"
rhai = { version = "=1.23.6", features = ["sync"] }
serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.145"
+5
View File
@@ -14,6 +14,7 @@
- Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。
- 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。
- 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。
- 账户出入金以独立外部现金流记录保存;权益曲线同时输出 `externalCashFlow` 和现金流中性 `unitNav`,收益指标不把入金/出金计入交易收益。延迟出金在结算前做整批资金校验,不能把账户现金变成负数。
- 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。
## Workspace 布局
@@ -80,6 +81,10 @@
任意数据库指标和自定义因子通过 `factor("field")``factor_value("field", lookback)``rolling_mean("field", n)``sma("close", n)` 等函数读取。未预计算的均线窗口可在回测中按已有历史数据实时计算。
Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。
`holdUntilExit=true``stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
## 内置微盘策略
`OmniMicroCapStrategy` 是平台内置的微盘轮动策略,用于 demo、性能验证和策略迁移基线:
+1
View File
@@ -8,6 +8,7 @@ authors.workspace = true
[dependencies]
chrono.workspace = true
indexmap.workspace = true
rayon.workspace = true
rhai.workspace = true
serde.workspace = true
serde_json.workspace = true
File diff suppressed because it is too large Load Diff
+23 -30
View File
@@ -5,17 +5,16 @@ use chrono::NaiveDate;
use crate::events::OrderSide;
use crate::risk_control::TradingConstraintConfig;
pub const STOCK_PIT_TAX_CHANGE_DATE: (i32, u32, u32) = (2023, 8, 28);
#[derive(Debug, Clone, Copy)]
pub struct TradingCost {
pub commission: f64,
pub stamp_tax: f64,
pub transfer_fee: f64,
}
impl TradingCost {
pub fn total(self) -> f64 {
self.commission + self.stamp_tax
self.commission + self.stamp_tax + self.transfer_fee
}
}
@@ -41,30 +40,16 @@ pub struct ChinaAShareCostModel {
pub stamp_tax_rate_after_change: f64,
pub stamp_tax_change_date: NaiveDate,
pub minimum_commission: f64,
pub transfer_fee_rate: f64,
}
impl Default for ChinaAShareCostModel {
fn default() -> Self {
Self {
commission_rate: 0.0008,
stamp_tax_rate_before_change: 0.001,
stamp_tax_rate_after_change: 0.0005,
stamp_tax_change_date: default_stamp_tax_change_date(),
minimum_commission: 5.0,
}
Self::from_trading_constraints(TradingConstraintConfig::default())
}
}
impl ChinaAShareCostModel {
pub fn aiquant_default() -> Self {
Self {
commission_rate: 0.0003,
stamp_tax_rate_before_change: 0.0005,
stamp_tax_rate_after_change: 0.0005,
..Self::default()
}
}
pub fn from_trading_constraints(config: TradingConstraintConfig) -> Self {
Self {
commission_rate: config.commission_rate,
@@ -72,6 +57,7 @@ impl ChinaAShareCostModel {
stamp_tax_rate_after_change: config.stamp_tax_rate_after_change,
stamp_tax_change_date: config.stamp_tax_change_date,
minimum_commission: config.minimum_commission,
transfer_fee_rate: config.transfer_fee_rate,
}
}
@@ -97,6 +83,13 @@ impl ChinaAShareCostModel {
gross_amount * self.stamp_tax_rate_for(date)
}
pub fn transfer_fee_for(&self, gross_amount: f64) -> f64 {
if gross_amount <= 0.0 {
return 0.0;
}
gross_amount * self.transfer_fee_rate
}
pub fn commission_for_order_fill(
&self,
gross_amount: f64,
@@ -135,30 +128,24 @@ impl ChinaAShareCostModel {
}
}
fn default_stamp_tax_change_date() -> NaiveDate {
NaiveDate::from_ymd_opt(
STOCK_PIT_TAX_CHANGE_DATE.0,
STOCK_PIT_TAX_CHANGE_DATE.1,
STOCK_PIT_TAX_CHANGE_DATE.2,
)
.expect("valid pit tax change date")
}
impl CostModel for ChinaAShareCostModel {
fn calculate(&self, date: NaiveDate, side: OrderSide, gross_amount: f64) -> TradingCost {
if gross_amount <= 0.0 {
return TradingCost {
commission: 0.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
};
}
let commission = self.commission_for(gross_amount);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
let transfer_fee = self.transfer_fee_for(gross_amount);
TradingCost {
commission,
stamp_tax,
transfer_fee,
}
}
@@ -174,15 +161,18 @@ impl CostModel for ChinaAShareCostModel {
return TradingCost {
commission: 0.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
};
}
let commission = self.commission_for_order_fill(gross_amount, order_id, commission_state);
let stamp_tax = self.stamp_tax_for(date, side, gross_amount);
let transfer_fee = self.transfer_fee_for(gross_amount);
TradingCost {
commission,
stamp_tax,
transfer_fee,
}
}
}
@@ -192,8 +182,8 @@ mod tests {
use super::*;
#[test]
fn aiquant_default_matches_current_backtest_fee_model() {
let model = ChinaAShareCostModel::aiquant_default();
fn default_matches_configurable_trading_constraints() {
let model = ChinaAShareCostModel::default();
let date = NaiveDate::from_ymd_opt(2025, 11, 11).expect("valid date");
assert!((model.commission_for(248_059.812) - 74.4179436).abs() < 1e-9);
@@ -207,6 +197,7 @@ mod tests {
let config = TradingConstraintConfig {
commission_rate: 0.0003,
minimum_commission: 5.0,
transfer_fee_rate: 0.00001,
stamp_tax_rate_before_change: 0.002,
stamp_tax_rate_after_change: 0.001,
stamp_tax_change_date: NaiveDate::from_ymd_opt(2025, 1, 10).expect("valid date"),
@@ -214,6 +205,8 @@ mod tests {
};
let model = ChinaAShareCostModel::from_trading_constraints(config);
assert!((model.transfer_fee_for(10_000.0) - 0.1).abs() < 1e-12);
assert!(
(model.stamp_tax_for(
NaiveDate::from_ymd_opt(2025, 1, 9).expect("valid date"),
+557 -132
View File
@@ -1,7 +1,8 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, OnceLock, RwLock};
use std::sync::{Arc, OnceLock};
use chrono::{NaiveDate, NaiveDateTime};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -10,6 +11,8 @@ use crate::futures::FuturesTradingParameter;
use crate::instrument::Instrument;
use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig};
const SOURCE_DAILY_VOLUME_AVAILABLE_FIELD: &str = "source_daily_volume_available";
mod date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
@@ -448,49 +451,12 @@ pub struct EligibleUniverseSnapshot {
pub free_float_cap_bn: f64,
}
pub fn decision_adjusted_cap_bn(
factor_date: NaiveDate,
raw_cap_bn: f64,
market: &DailyMarketSnapshot,
) -> f64 {
if !raw_cap_bn.is_finite() || raw_cap_bn <= 0.0 {
return f64::NAN;
}
if factor_date != market.date {
return raw_cap_bn;
}
if !market.close.is_finite()
|| market.close <= 0.0
|| !market.prev_close.is_finite()
|| market.prev_close <= 0.0
{
return f64::NAN;
}
raw_cap_bn * market.prev_close / market.close
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
factor.market_cap_bn
}
fn factor_market_cap_is_decision_adjusted(factor: &DailyFactorSnapshot) -> bool {
factor
.extra_factors
.get("__market_cap_decision_adjusted")
.is_some_and(|value| value.is_finite() && *value > 0.0)
}
pub fn decision_market_cap_bn(factor: &DailyFactorSnapshot, market: &DailyMarketSnapshot) -> f64 {
if factor_market_cap_is_decision_adjusted(factor) {
return factor.market_cap_bn;
}
decision_adjusted_cap_bn(factor.date, factor.market_cap_bn, market)
}
pub fn decision_free_float_cap_bn(
factor: &DailyFactorSnapshot,
market: &DailyMarketSnapshot,
) -> f64 {
if factor_market_cap_is_decision_adjusted(factor) {
return factor.free_float_cap_bn;
}
decision_adjusted_cap_bn(factor.date, factor.free_float_cap_bn, market)
pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
factor.free_float_cap_bn
}
#[derive(Debug, Clone)]
@@ -523,6 +489,106 @@ struct SymbolPriceSeries {
volume_prefix: Vec<f64>,
}
#[derive(Debug, Clone)]
struct AdjustedCloseSeries {
dates: Vec<NaiveDate>,
backward_factors: Vec<Option<f64>>,
back_adjusted_closes: Vec<Option<f64>>,
back_adjusted_close_prefix: Vec<f64>,
missing_back_adjusted_close_prefix: Vec<u32>,
}
impl AdjustedCloseSeries {
fn new(
market: &SymbolPriceSeries,
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
) -> Option<Self> {
let mut backward_factors = Vec::with_capacity(market.dates.len());
let mut back_adjusted_closes = Vec::with_capacity(market.dates.len());
let mut back_adjusted_close_prefix = Vec::with_capacity(market.dates.len() + 1);
let mut missing_back_adjusted_close_prefix = Vec::with_capacity(market.dates.len() + 1);
back_adjusted_close_prefix.push(0.0);
missing_back_adjusted_close_prefix.push(0);
for (date, close) in market.dates.iter().zip(&market.closes) {
let factor = factor_by_date
.get(date)
.and_then(|rows| {
find_arc_by_symbol(rows, &market.symbol, |row| row.symbol.as_str())
})
.and_then(|snapshot| factor_numeric_value(snapshot, "adjustment_factor_backward1"))
.filter(|factor| factor.is_finite() && *factor > 0.0);
let back_adjusted_close = factor
.filter(|_| close.is_finite() && *close > 0.0)
.map(|factor| close * factor);
backward_factors.push(factor);
back_adjusted_closes.push(back_adjusted_close);
back_adjusted_close_prefix.push(
back_adjusted_close_prefix
.last()
.copied()
.unwrap_or_default()
+ back_adjusted_close.unwrap_or_default(),
);
missing_back_adjusted_close_prefix.push(
missing_back_adjusted_close_prefix
.last()
.copied()
.unwrap_or_default()
+ u32::from(back_adjusted_close.is_none()),
);
}
Some(Self {
dates: market.dates.clone(),
backward_factors,
back_adjusted_closes,
back_adjusted_close_prefix,
missing_back_adjusted_close_prefix,
})
}
fn current_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 {
return None;
}
let end = match self.dates.binary_search(&date) {
Ok(index) => index + 1,
Err(0) => return None,
Err(index) => index,
};
if end < lookback {
return None;
}
let base_factor = self.backward_factors.get(end - 1).copied().flatten()?;
let start = end - lookback;
if self.missing_back_adjusted_close_prefix[end]
!= self.missing_back_adjusted_close_prefix[start]
{
return None;
}
let sum = self.back_adjusted_close_prefix[end] - self.back_adjusted_close_prefix[start];
if !sum.is_finite() {
return None;
}
Some(normalize_rolling_factor(
sum / lookback as f64 / base_factor,
12,
))
}
fn latest_back_adjusted_close(&self, date: NaiveDate) -> Option<f64> {
let index = match self.dates.binary_search(&date) {
Ok(index) => index,
Err(0) => return None,
Err(index) => index - 1,
};
self.back_adjusted_closes
.get(index)
.copied()
.flatten()
.filter(|value| value.is_finite() && *value > 0.0)
}
}
impl SymbolPriceSeries {
fn new<'a, I>(symbol: String, rows: I) -> Self
where
@@ -739,7 +805,10 @@ impl SymbolPriceSeries {
}
let start = end - lookback;
let sum = self.volume_prefix[end] - self.volume_prefix[start];
Some(sum / lookback as f64)
if !sum.is_finite() {
return None;
}
Some(normalize_rolling_factor(sum / lookback as f64, 6))
}
fn decision_volume_values(&self, date: NaiveDate, lookback: usize) -> Option<Vec<f64>> {
@@ -984,9 +1053,12 @@ pub struct DataSet {
execution_quotes_by_date: HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>,
order_book_depth_index: HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>,
benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>,
market_series_by_symbol: Arc<RwLock<HashMap<String, Arc<SymbolPriceSeries>>>>,
market_series_by_symbol: Arc<HashMap<String, Arc<SymbolPriceSeries>>>,
adjusted_close_series_by_symbol: Arc<HashMap<String, Arc<AdjustedCloseSeries>>>,
benchmark_series_cache: BenchmarkPriceSeries,
eligible_universe_by_date: Arc<OnceLock<BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>>>>,
source_daily_volume_contract_symbols: HashSet<String>,
source_daily_volume_missing_dates_by_symbol: HashMap<String, Vec<NaiveDate>>,
benchmark_code: String,
futures_params_by_symbol: HashMap<String, Vec<FuturesTradingParameter>>,
}
@@ -1134,10 +1206,30 @@ impl DataSet {
) -> Result<Self, DataSetError> {
let benchmark_code = collect_benchmark_code(&benchmarks)?;
let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect());
let factors = normalize_factor_snapshots(factors)
.into_iter()
.map(Arc::new)
.collect::<Vec<_>>();
let factors = normalize_factor_snapshots(factors);
let mut source_daily_volume_contract_symbols = HashSet::new();
let mut source_daily_volume_missing_dates_by_symbol =
HashMap::<String, Vec<NaiveDate>>::new();
for snapshot in &factors {
let Some(available) = snapshot
.extra_factors
.get(SOURCE_DAILY_VOLUME_AVAILABLE_FIELD)
else {
continue;
};
source_daily_volume_contract_symbols.insert(snapshot.symbol.clone());
if *available < 0.5 {
source_daily_volume_missing_dates_by_symbol
.entry(snapshot.symbol.clone())
.or_default()
.push(snapshot.date);
}
}
for dates in source_daily_volume_missing_dates_by_symbol.values_mut() {
dates.sort_unstable();
dates.dedup();
}
let factors = factors.into_iter().map(Arc::new).collect::<Vec<_>>();
let candidates = candidates.into_iter().map(Arc::new).collect::<Vec<_>>();
let instruments = instruments
@@ -1151,6 +1243,27 @@ impl DataSet {
let mut factor_by_date = group_arc_by_date(&factors, |item| item.date);
sort_arc_groups_by_symbol(&mut factor_by_date, |item| item.symbol.as_str());
let mut market_rows_by_symbol = HashMap::<String, Vec<&DailyMarketSnapshot>>::new();
for row in &market {
market_rows_by_symbol
.entry(row.symbol.clone())
.or_default()
.push(row.as_ref());
}
let market_series_by_symbol = market_rows_by_symbol
.into_par_iter()
.map(|(symbol, rows)| {
let series = Arc::new(SymbolPriceSeries::new(symbol.clone(), rows));
(symbol, series)
})
.collect::<HashMap<_, _>>();
let adjusted_close_series_by_symbol = market_series_by_symbol
.par_iter()
.filter_map(|(symbol, market)| {
AdjustedCloseSeries::new(market, &factor_by_date)
.map(|series| (symbol.clone(), Arc::new(series)))
})
.collect::<HashMap<_, _>>();
let factor_texts = factor_texts
.into_iter()
.filter_map(|mut item| {
@@ -1194,9 +1307,12 @@ impl DataSet {
execution_quotes_by_date,
order_book_depth_index,
benchmark_by_date,
market_series_by_symbol: Arc::new(RwLock::new(HashMap::new())),
market_series_by_symbol: Arc::new(market_series_by_symbol),
adjusted_close_series_by_symbol: Arc::new(adjusted_close_series_by_symbol),
benchmark_series_cache,
eligible_universe_by_date: Arc::new(OnceLock::new()),
source_daily_volume_contract_symbols,
source_daily_volume_missing_dates_by_symbol,
benchmark_code,
futures_params_by_symbol,
})
@@ -1246,36 +1362,11 @@ impl DataSet {
}
fn market_series(&self, symbol: &str) -> Option<Arc<SymbolPriceSeries>> {
if let Some(series) = self
.market_series_by_symbol
.read()
.expect("market series cache lock poisoned")
.get(symbol)
.cloned()
{
return Some(series);
}
self.market_series_by_symbol.get(symbol).cloned()
}
let rows = self
.market_by_date
.values()
.filter_map(|day_rows| find_arc_by_symbol(day_rows, symbol, |row| row.symbol.as_str()))
.collect::<Vec<_>>();
if rows.is_empty() {
return None;
}
let series = Arc::new(SymbolPriceSeries::new(symbol.to_string(), rows));
let mut cache = self
.market_series_by_symbol
.write()
.expect("market series cache lock poisoned");
Some(
cache
.entry(symbol.to_string())
.or_insert_with(|| Arc::clone(&series))
.clone(),
)
fn adjusted_close_series(&self, symbol: &str) -> Option<Arc<AdjustedCloseSeries>> {
self.adjusted_close_series_by_symbol.get(symbol).cloned()
}
pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> {
@@ -2322,9 +2413,14 @@ impl DataSet {
"close" | "prev_close" | "stock_close" | "price" => self
.market_series(symbol)
.and_then(|series| series.decision_close_moving_average(date, lookback)),
"volume" | "stock_volume" => self
.market_series(symbol)
.and_then(|series| series.decision_volume_moving_average(date, lookback)),
"volume" | "stock_volume" => {
if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
None
} else {
self.market_series(symbol)
.and_then(|series| series.decision_volume_moving_average(date, lookback))
}
}
"day_open" | "dayopen" => {
self.market_moving_average(date, symbol, lookback, PriceField::DayOpen)
}
@@ -2345,13 +2441,20 @@ impl DataSet {
) -> Option<f64> {
let field = normalize_field(field);
match field.as_str() {
"close" | "prev_close" | "stock_close" | "price" => {
self.market_moving_average(date, symbol, lookback, PriceField::Close)
"close" | "prev_close" | "stock_close" | "price" => self
.adjusted_close_series(symbol)
.and_then(|series| series.current_moving_average(date, lookback)),
"volume" | "stock_volume" => {
if !self.source_daily_volume_window_available(date, symbol, lookback, true) {
None
} else {
self.market_series(symbol)
.and_then(|series| series.current_volume_moving_average(date, lookback))
.or_else(|| {
self.factor_moving_average(date, symbol, "daily_volume", lookback)
})
}
}
"volume" | "stock_volume" => self
.market_series(symbol)
.and_then(|series| series.current_volume_moving_average(date, lookback))
.or_else(|| self.factor_moving_average(date, symbol, "daily_volume", lookback)),
"day_open" | "dayopen" => {
self.market_moving_average(date, symbol, lookback, PriceField::DayOpen)
}
@@ -2363,6 +2466,11 @@ impl DataSet {
}
}
pub fn market_latest_back_adjusted_close(&self, date: NaiveDate, symbol: &str) -> Option<f64> {
self.adjusted_close_series(symbol)
.and_then(|series| series.latest_back_adjusted_close(date))
}
pub fn market_decision_numeric_values(
&self,
date: NaiveDate,
@@ -2379,10 +2487,15 @@ impl DataSet {
.market_series(symbol)
.and_then(|series| series.decision_prev_close_values(date, lookback))
.unwrap_or_default(),
"volume" | "stock_volume" => self
.market_series(symbol)
.and_then(|series| series.decision_volume_values(date, lookback))
.unwrap_or_default(),
"volume" | "stock_volume" => {
if !self.source_daily_volume_window_available(date, symbol, lookback, false) {
Vec::new()
} else {
self.market_series(symbol)
.and_then(|series| series.decision_volume_values(date, lookback))
.unwrap_or_default()
}
}
"day_open" | "dayopen" => self
.market_series(symbol)
.map(|series| series.trailing_values(date, lookback, PriceField::DayOpen))
@@ -2399,6 +2512,67 @@ impl DataSet {
}
}
pub fn market_current_numeric_values(
&self,
date: NaiveDate,
symbol: &str,
field: &str,
lookback: usize,
) -> Vec<f64> {
let field = normalize_field(field);
if matches!(field.as_str(), "volume" | "stock_volume")
&& !self.source_daily_volume_window_available(date, symbol, lookback, true)
{
return Vec::new();
}
self.market_series(symbol)
.map(|series| series.trailing_numeric_values(date, lookback, &field, true))
.unwrap_or_default()
}
fn source_daily_volume_window_available(
&self,
date: NaiveDate,
symbol: &str,
lookback: usize,
include_now: bool,
) -> bool {
if !self.source_daily_volume_contract_symbols.contains(symbol) {
return true;
}
if lookback == 0 {
return false;
}
let end = if include_now && self.calendar.index_of(date).is_some() {
date
} else {
let Some(previous) = self.calendar.previous_trading_date(date, 1) else {
return false;
};
previous
};
let dates = self.calendar.trailing_days(end, lookback);
if dates.len() != lookback {
return false;
}
let Some(series) = self.market_series(symbol) else {
return false;
};
if dates
.iter()
.any(|trading_day| series.dates.binary_search(trading_day).is_err())
{
return false;
}
let Some(missing_dates) = self.source_daily_volume_missing_dates_by_symbol.get(symbol)
else {
return true;
};
!dates
.iter()
.any(|trading_day| missing_dates.binary_search(trading_day).is_ok())
}
pub fn factor_numeric_values(
&self,
date: NaiveDate,
@@ -2933,6 +3107,11 @@ fn prefix_sums(values: &[f64]) -> Vec<f64> {
prefix
}
fn normalize_rolling_factor(value: f64, decimals: i32) -> f64 {
let scale = 10_f64.powi(decimals);
(value * scale).round() / scale
}
mod optional_date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
@@ -3048,20 +3227,21 @@ fn build_fundamental_universe_for_date(
return rows;
};
for factor in factors {
let Some(market) = market_by_date
if market_by_date
.get(&date)
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
else {
.is_none()
{
continue;
};
let market_cap_bn = decision_market_cap_bn(factor, market);
}
let market_cap_bn = decision_market_cap_bn(factor);
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
continue;
}
rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(),
market_cap_bn,
free_float_cap_bn: decision_free_float_cap_bn(factor, market),
free_float_cap_bn: decision_free_float_cap_bn(factor),
});
}
rows.sort_by(|left, right| {
@@ -3136,11 +3316,11 @@ fn build_eligible_universe_for_date_from_factors(
{
continue;
}
let market_cap_bn = decision_market_cap_bn(factor, market);
let market_cap_bn = decision_market_cap_bn(factor);
if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() {
continue;
}
let free_float_cap_bn = decision_free_float_cap_bn(factor, market);
let free_float_cap_bn = decision_free_float_cap_bn(factor);
rows.push(EligibleUniverseSnapshot {
symbol: factor.symbol.clone(),
market_cap_bn,
@@ -3306,6 +3486,113 @@ mod tests {
);
}
fn volume_contract_data(availability: Option<[f64; 3]>) -> DataSet {
let dates = [
NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(),
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
];
let volumes = [100_u64, 0, 300];
DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "000001.SZ".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(dates[0]),
delisted_at: None,
status: "active".to_string(),
}],
dates
.iter()
.zip(volumes)
.map(|(date, volume)| {
market_row(&date.format("%Y-%m-%d").to_string(), 10.0, volume)
})
.collect(),
dates
.iter()
.enumerate()
.map(|(index, date)| {
let mut extra_factors = BTreeMap::new();
if let Some(values) = availability {
extra_factors.insert(
SOURCE_DAILY_VOLUME_AVAILABLE_FIELD.to_string(),
values[index],
);
if values[index] >= 0.5 {
extra_factors.insert("daily_volume".to_string(), volumes[index] as f64);
}
}
DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors,
}
})
.collect(),
Vec::new(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000852.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 1_000_000,
})
.collect(),
)
.expect("volume contract dataset")
}
#[test]
fn source_volume_contract_rejects_windows_containing_missing_values() {
let data = volume_contract_data(Some([1.0, 0.0, 1.0]));
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
assert_eq!(
data.market_current_numeric_moving_average(date, "000001.SZ", "volume", 3),
None
);
assert!(
data.market_current_numeric_values(date, "000001.SZ", "volume", 3)
.is_empty()
);
assert_eq!(
data.market_decision_numeric_moving_average(date, "000001.SZ", "volume", 2),
None
);
assert!(
data.market_decision_numeric_values(date, "000001.SZ", "volume", 2)
.is_empty()
);
}
#[test]
fn source_volume_contract_keeps_valid_zero_volume_and_legacy_data() {
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
for data in [
volume_contract_data(Some([1.0, 1.0, 1.0])),
volume_contract_data(None),
] {
assert_eq!(
data.market_current_numeric_moving_average(date, "000001.SZ", "volume", 3),
Some(133.333333)
);
assert_eq!(
data.market_current_numeric_values(date, "000001.SZ", "volume", 3),
vec![100.0, 0.0, 300.0]
);
}
}
#[test]
fn decision_close_average_ignores_current_day_close() {
let mut current = market_row("2025-01-06", 12.0, 10_000);
@@ -3331,6 +3618,169 @@ mod tests {
);
}
#[test]
fn current_close_average_uses_backward_adjustment_factor_and_current_base() {
let dates = [
NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(),
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
];
let factors = [1.0, 1.0, 2.0];
let closes = [10.0, 11.0, 6.0];
let data = DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "000001.SZ".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(dates[0]),
delisted_at: None,
status: "active".to_string(),
}],
dates
.iter()
.zip(closes)
.map(|(date, close)| market_row(&date.format("%Y-%m-%d").to_string(), close, 100))
.collect(),
dates
.iter()
.zip(factors)
.map(|(date, factor)| DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: BTreeMap::from([(
"adjustment_factor_backward1".to_string(),
factor,
)]),
})
.collect(),
Vec::new(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000852.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 1_000_000,
})
.collect(),
)
.expect("dataset");
assert_eq!(
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
Some(5.5)
);
assert_ne!(
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
data.market_moving_average(dates[2], "000001.SZ", 3, PriceField::Close)
);
}
#[test]
fn adjusted_close_average_normalization_prevents_strict_crossover_drift() {
let pattern = [
2.953, 1.093, 2.717, 1.579, 1.289, 1.236, 1.617, 2.632, 1.361, 2.163,
];
let start = NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d").unwrap();
let values = (0..30)
.map(|index| pattern[index % pattern.len()])
.collect::<Vec<_>>();
let series = AdjustedCloseSeries {
dates: (0..30)
.map(|index| start + chrono::Duration::days(index as i64))
.collect(),
backward_factors: vec![Some(1.0); 30],
back_adjusted_closes: values.iter().copied().map(Some).collect(),
back_adjusted_close_prefix: prefix_sums(&values),
missing_back_adjusted_close_prefix: vec![0; 31],
};
let date = *series.dates.last().expect("last date");
assert_eq!(
series.current_moving_average(date, 10),
series.current_moving_average(date, 30)
);
}
#[test]
fn future_missing_adjustment_factor_does_not_invalidate_historical_window() {
let dates = [
NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(),
NaiveDate::parse_from_str("2025-01-03", "%Y-%m-%d").unwrap(),
NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(),
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
];
let data = DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "000001.SZ".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(dates[0]),
delisted_at: None,
status: "active".to_string(),
}],
dates
.iter()
.enumerate()
.map(|(index, date)| {
market_row(
&date.format("%Y-%m-%d").to_string(),
10.0 + index as f64,
100,
)
})
.collect(),
dates
.iter()
.map(|date| DailyFactorSnapshot {
date: *date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 10.0,
turnover_ratio: None,
effective_turnover_ratio: None,
extra_factors: if *date == dates[3] {
BTreeMap::new()
} else {
BTreeMap::from([("adjustment_factor_backward1".to_string(), 1.0)])
},
})
.collect(),
Vec::new(),
dates
.iter()
.map(|date| BenchmarkSnapshot {
date: *date,
benchmark: "000852.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 1_000_000,
})
.collect(),
)
.expect("dataset");
assert_eq!(
data.market_current_numeric_moving_average(dates[2], "000001.SZ", "close", 3),
Some(11.0)
);
assert_eq!(
data.market_current_numeric_moving_average(dates[3], "000001.SZ", "close", 3),
None
);
}
#[test]
fn decision_volume_average_includes_paused_zero_volume_days() {
let mut paused = market_row("2025-01-03", 11.0, 0);
@@ -3444,11 +3894,11 @@ mod tests {
let rows = data.eligible_universe_on(date);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].symbol, "000001.SZ");
assert!((rows[0].market_cap_bn - 6.0).abs() < 1e-9);
assert!((rows[0].free_float_cap_bn - 2.0).abs() < 1e-9);
assert_eq!(rows[1].symbol, "000002.SZ");
assert!((rows[1].market_cap_bn - 10.0).abs() < 1e-9);
assert_eq!(rows[0].symbol, "000002.SZ");
assert!((rows[0].market_cap_bn - 10.0).abs() < 1e-9);
assert_eq!(rows[1].symbol, "000001.SZ");
assert!((rows[1].market_cap_bn - 12.0).abs() < 1e-9);
assert!((rows[1].free_float_cap_bn - 4.0).abs() < 1e-9);
}
#[test]
@@ -3621,33 +4071,8 @@ mod tests {
}
#[test]
fn decision_market_cap_keeps_pre_adjusted_factor() {
fn decision_market_cap_uses_factor_date_snapshot_without_price_reconstruction() {
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
let market = DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 20.0,
low: 10.0,
close: 20.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
minute_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
};
let mut extra_factors = BTreeMap::new();
extra_factors.insert("__market_cap_decision_adjusted".to_string(), 1.0);
let factor = DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
@@ -3656,11 +4081,11 @@ mod tests {
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors,
extra_factors: BTreeMap::new(),
};
assert!((decision_market_cap_bn(&factor, &market) - 12.0).abs() < 1e-9);
assert!((decision_free_float_cap_bn(&factor, &market) - 4.0).abs() < 1e-9);
assert!((decision_market_cap_bn(&factor) - 12.0).abs() < 1e-9);
assert!((decision_free_float_cap_bn(&factor) - 4.0).abs() < 1e-9);
}
#[test]
+252 -130
View File
@@ -78,6 +78,13 @@ pub struct DailyEquityPoint {
pub cash: f64,
pub market_value: f64,
pub total_equity: f64,
/// External cash flow settled on this trading date (deposit positive,
/// withdrawal negative). Trading cash movements are excluded.
#[serde(default)]
pub external_cash_flow: f64,
/// Cash-flow-neutral unit NAV after all activity on this date.
#[serde(default)]
pub unit_nav: f64,
pub benchmark_close: f64,
pub benchmark_prev_close: f64,
pub notes: String,
@@ -196,7 +203,7 @@ impl BacktestResult {
quantity: fill.quantity,
price: fill.price,
gross_amount: fill.gross_amount,
transaction_cost: fill.commission + fill.stamp_tax,
transaction_cost: fill.commission + fill.stamp_tax + fill.transfer_fee,
net_cash_flow: fill.net_cash_flow,
reason: fill.reason.clone(),
})
@@ -230,27 +237,34 @@ impl BacktestResult {
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
let mut month_points = BTreeMap::<(i32, u32), (f64, f64, f64, f64)>::new();
let mut previous_equity = self.metrics.initial_cash;
let mut previous_equity = 1.0;
let mut previous_benchmark = self
.equity_curve
.first()
.map(|point| point.benchmark_prev_close)
.unwrap_or_default();
for point in &self.equity_curve {
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
point.unit_nav
} else if self.metrics.initial_cash.abs() > f64::EPSILON {
point.total_equity / self.metrics.initial_cash
} else {
1.0
};
let key = (point.date.year(), point.date.month());
month_points
.entry(key)
.and_modify(|(_, _, end_equity, end_benchmark)| {
*end_equity = point.total_equity;
*end_equity = point_nav;
*end_benchmark = point.benchmark_close;
})
.or_insert((
previous_equity,
previous_benchmark,
point.total_equity,
point_nav,
point.benchmark_close,
));
previous_equity = point.total_equity;
previous_equity = point_nav;
previous_benchmark = point.benchmark_close;
}
month_points
@@ -299,6 +313,8 @@ pub struct BacktestDayProgress {
pub cash: f64,
pub market_value: f64,
pub total_equity: f64,
#[serde(default)]
pub external_cash_flow: f64,
pub unit_nav: f64,
pub total_return: f64,
pub benchmark_close: f64,
@@ -1728,6 +1744,8 @@ where
daily_holdings: Vec::new(),
metrics: BacktestMetrics::default(),
};
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total();
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
let mut corporate_action_notes = Vec::new();
@@ -1739,7 +1757,7 @@ where
execution_date,
&mut portfolio,
&mut corporate_action_notes,
);
)?;
self.extend_result(
&mut result,
pending_cash_flow_report,
@@ -1768,11 +1786,11 @@ where
execution_date,
execution_date,
);
let delisting_report = self.settle_delisted_positions(
let delisting_report = self.audit_unresolved_delisted_positions(
execution_date,
&mut portfolio,
&portfolio,
&mut corporate_action_notes,
)?;
);
self.extend_result(
&mut result,
delisting_report,
@@ -1828,16 +1846,21 @@ where
.join(" | ");
let holdings_for_day = portfolio.holdings_summary(execution_date);
let day_process_events = process_events.clone();
let aggregate_initial_cash = self.aggregate_initial_cash();
let aggregate_cash = self.aggregate_cash(&portfolio);
let aggregate_market_value = self.aggregate_market_value(&portfolio);
let aggregate_total_equity = self.aggregate_total_equity(&portfolio);
let unit_nav = portfolio.unit_net_value();
let external_cash_flow =
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
total_equity: aggregate_total_equity,
external_cash_flow,
unit_nav,
benchmark_close: benchmark.close,
benchmark_prev_close: benchmark.prev_close,
notes,
@@ -1853,16 +1876,9 @@ where
cash: latest.cash,
market_value: latest.market_value,
total_equity: latest.total_equity,
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
latest.total_equity / aggregate_initial_cash
},
total_return: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
(latest.total_equity / aggregate_initial_cash) - 1.0
},
external_cash_flow: latest.external_cash_flow,
unit_nav: latest.unit_nav,
total_return: latest.unit_nav - 1.0,
benchmark_close: latest.benchmark_close,
daily_fill_count,
cumulative_trade_count: result.fills.len(),
@@ -1875,8 +1891,12 @@ where
process_events: day_process_events,
});
result.process_events.append(&mut process_events);
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
continue;
};
let decision_total_equity = (decision_date < execution_date)
.then(|| stock_equity_by_date.get(&decision_date).copied())
.flatten();
let mut process_events = Vec::new();
let mut directive_report = BrokerExecutionReport::default();
let pre_open_orders = self.open_order_views();
@@ -2073,10 +2093,11 @@ where
None,
None,
)?;
let mut report = self.broker.execute_with_event_dates(
let mut report = self.broker.execute_with_event_dates_and_decision_equity(
execution_date,
decision_date,
decision_date,
decision_total_equity,
&mut portfolio,
&self.data,
&auction_decision,
@@ -2321,10 +2342,11 @@ where
None,
None,
)?;
let mut intraday_report = self.broker.execute_with_event_dates(
let mut intraday_report = self.broker.execute_with_event_dates_and_decision_equity(
execution_date,
decision_date,
decision_date,
decision_total_equity,
&mut portfolio,
&self.data,
&decision,
@@ -2492,16 +2514,19 @@ where
Some(minute_time),
Some(minute_time),
)?;
let mut minute_report = self.broker.execute_between_with_event_dates(
execution_date,
decision_date,
decision_date,
&mut portfolio,
&self.data,
&minute_decision,
Some(minute_time),
Some(minute_time),
)?;
let mut minute_report = self
.broker
.execute_between_with_event_dates_and_decision_equity(
execution_date,
decision_date,
decision_date,
decision_total_equity,
&mut portfolio,
&self.data,
&minute_decision,
Some(minute_time),
Some(minute_time),
)?;
let post_minute_open_orders = self.open_order_views();
publish_process_events(
&mut self.strategy,
@@ -2841,16 +2866,21 @@ where
.join(" | ");
let holdings_for_day = portfolio.holdings_summary(execution_date);
let day_process_events = process_events.clone();
let aggregate_initial_cash = self.aggregate_initial_cash();
let aggregate_cash = self.aggregate_cash(&portfolio);
let aggregate_market_value = self.aggregate_market_value(&portfolio);
let aggregate_total_equity = self.aggregate_total_equity(&portfolio);
let unit_nav = portfolio.unit_net_value();
let external_cash_flow =
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
total_equity: aggregate_total_equity,
external_cash_flow,
unit_nav,
benchmark_close: benchmark.close,
benchmark_prev_close: benchmark.prev_close,
notes,
@@ -2866,16 +2896,9 @@ where
cash: latest.cash,
market_value: latest.market_value,
total_equity: latest.total_equity,
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
latest.total_equity / aggregate_initial_cash
},
total_return: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
(latest.total_equity / aggregate_initial_cash) - 1.0
},
external_cash_flow: latest.external_cash_flow,
unit_nav: latest.unit_nav,
total_return: latest.unit_nav - 1.0,
benchmark_close: latest.benchmark_close,
daily_fill_count,
cumulative_trade_count: result.fills.len(),
@@ -2888,6 +2911,7 @@ where
process_events: day_process_events,
});
result.process_events.extend(process_events);
stock_equity_by_date.insert(execution_date, portfolio.total_equity());
}
if let Some(last_date) = execution_dates.last().copied() {
@@ -2897,6 +2921,7 @@ where
&result.equity_curve,
&result.fills,
&result.daily_holdings,
&result.account_events,
self.aggregate_initial_cash(),
);
@@ -3145,6 +3170,7 @@ where
gross_amount: reinvest_cash,
commission: 0.0,
stamp_tax: 0.0,
transfer_fee: 0.0,
net_cash_flow: -reinvest_cash,
reason: "dividend_reinvestment".to_string(),
});
@@ -3194,9 +3220,12 @@ where
date: NaiveDate,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
) -> BrokerExecutionReport {
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
for flow in portfolio.settle_pending_cash_flows(date) {
for flow in portfolio
.settle_pending_cash_flows(date)
.map_err(BacktestError::Execution)?
{
let cash_before = portfolio.cash() - flow.amount;
let note = format!(
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
@@ -3211,7 +3240,7 @@ where
note,
});
}
report
Ok(report)
}
fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport {
@@ -3375,13 +3404,13 @@ where
Ok(report)
}
fn settle_delisted_positions(
fn audit_unresolved_delisted_positions(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
portfolio: &PortfolioState,
notes: &mut Vec<String>,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
) -> BrokerExecutionReport {
let report = BrokerExecutionReport::default();
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
for symbol in symbols {
let Some(position) = portfolio.position(&symbol) else {
@@ -3393,98 +3422,29 @@ where
let Some(instrument) = self.data.instrument(&symbol) else {
continue;
};
let should_settle = instrument.is_delisted_on_or_before(date)
let is_unresolved = instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none()
&& self.data.market(date, &symbol).is_none());
if !should_settle {
if !is_unresolved {
continue;
}
let quantity = position.quantity;
let settlement_price = if position.last_price.is_finite() && position.last_price > 0.0 {
position.last_price
} else if position.average_cost.is_finite() && position.average_cost > 0.0 {
position.average_cost
} else {
0.0
};
let effective_delisted_at = instrument
.delisted_at
.or_else(|| self.data.calendar().previous_day(date))
.unwrap_or(date);
if !settlement_price.is_finite() || settlement_price <= 0.0 {
return Err(BacktestError::Execution(format!(
"missing delisting settlement price for {} on {}",
symbol, date
)));
}
let cash_before = portfolio.cash();
let gross_amount = settlement_price * quantity as f64;
let realized_pnl_delta = {
let position = portfolio
.position_mut_if_exists(&symbol)
.expect("position exists for delisting settlement");
position
.sell(quantity, settlement_price)
.map_err(BacktestError::Execution)?
};
portfolio.apply_cash_delta(gross_amount);
portfolio.prune_flat_positions();
let reason = format!(
"delisted_cash_settlement effective_date={} status={}",
effective_delisted_at, instrument.status
concat!(
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
"settlement_action=missing valuation_policy=zero no_order=true"
),
symbol, position.quantity, effective_delisted_at, instrument.status
);
notes.push(reason.clone());
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: None,
symbol: symbol.clone(),
side: OrderSide::Sell,
requested_quantity: quantity,
filled_quantity: quantity,
status: OrderStatus::Filled,
reason: reason.clone(),
});
report.fill_events.push(FillEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: None,
symbol: symbol.clone(),
side: OrderSide::Sell,
quantity,
price: settlement_price,
gross_amount,
commission: 0.0,
stamp_tax: 0.0,
net_cash_flow: gross_amount,
reason: reason.clone(),
});
report.position_events.push(PositionEvent {
date,
symbol: symbol.clone(),
delta_quantity: -(quantity as i32),
quantity_after: 0,
average_cost: 0.0,
realized_pnl_delta,
reason: reason.clone(),
});
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: portfolio.total_equity(),
note: reason,
});
if instrument.delisted_at == Some(date) || instrument.delisted_at.is_none() {
notes.push(reason.clone());
}
}
Ok(report)
report
}
}
@@ -4286,6 +4246,80 @@ mod tests {
}
}
#[derive(Debug)]
struct ScheduledTargetPortfolioSmartStrategy {
rule: ScheduleRule,
decision_date: NaiveDate,
target_weights: BTreeMap<String, f64>,
}
impl Strategy for ScheduledTargetPortfolioSmartStrategy {
fn name(&self) -> &str {
"scheduled_target_portfolio_smart"
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![self.rule.clone()]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
rule: &ScheduleRule,
) -> Result<StrategyDecision, super::BacktestError> {
assert_eq!(rule.name, self.rule.name);
if ctx.decision_date != self.decision_date {
return Ok(StrategyDecision::default());
}
Ok(StrategyDecision {
order_intents: vec![OrderIntent::TargetPortfolioSmart {
target_weights: self.target_weights.clone(),
order_prices: None,
valuation_prices: None,
reason: "scheduled_target_portfolio_smart".to_string(),
}],
..StrategyDecision::default()
})
}
}
#[derive(Debug)]
struct ScheduledTargetPercentStrategy {
first_decision_date: NaiveDate,
second_decision_date: NaiveDate,
}
impl Strategy for ScheduledTargetPercentStrategy {
fn name(&self) -> &str {
"scheduled_target_percent"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, super::BacktestError> {
let order_intents = if ctx.decision_date == self.first_decision_date {
vec![OrderIntent::Shares {
symbol: SYMBOL.to_string(),
quantity: 1_000,
reason: "initial_position".to_string(),
}]
} else if ctx.decision_date == self.second_decision_date {
vec![OrderIntent::TargetPercent {
symbol: SYMBOL.to_string(),
target_percent: 0.5,
reason: "frozen_target_percent".to_string(),
}]
} else {
Vec::new()
};
Ok(StrategyDecision {
order_intents,
..StrategyDecision::default()
})
}
}
#[derive(Debug)]
struct ScheduledEligibleUniverseBuyStrategy {
rule: ScheduleRule,
@@ -4921,6 +4955,94 @@ mod tests {
assert_eq!(result.fills[0].price, 12.0);
}
#[test]
fn next_bar_open_target_portfolio_smart_sizes_with_execution_day_open() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let dataset = DataSet::from_components(
vec![default_instrument()],
vec![market(first, 10.0, 10.0), market(second, 12.0, 12.0)],
vec![factor(first), factor(second)],
vec![candidate(first), candidate(second)],
vec![benchmark(first), benchmark(second)],
)
.expect("dataset");
let broker = scheduled_next_open_broker(FidcRiskControlConfig::default());
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(second),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
let mut target_weights = BTreeMap::new();
target_weights.insert(SYMBOL.to_string(), 1.0);
let result = BacktestEngine::new(
dataset,
ScheduledTargetPortfolioSmartStrategy {
rule: ScheduleRule::daily("daily_target_portfolio", ScheduleStage::OnDay),
decision_date: first,
target_weights,
},
broker,
config,
)
.run()
.expect("backtest run");
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].decision_date, Some(first));
assert_eq!(result.fills[0].execution_date, Some(second));
assert_eq!(result.fills[0].price, 12.0);
assert_eq!(result.fills[0].quantity, 8_300);
}
#[test]
fn next_bar_open_target_percent_freezes_decision_day_equity() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let dataset = dataset_from_market_and_candidates(
vec![
market(first, 10.0, 10.0),
market(second, 10.0, 10.0),
market(third, 20.0, 20.0),
],
vec![candidate(first), candidate(second), candidate(third)],
);
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(third),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
let result = BacktestEngine::new(
dataset,
ScheduledTargetPercentStrategy {
first_decision_date: first,
second_decision_date: second,
},
scheduled_next_open_broker(FidcRiskControlConfig::default()),
config,
)
.run()
.expect("backtest run");
assert_eq!(result.fills.len(), 2, "fills={:?}", result.fills);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].quantity, 1_000);
assert_eq!(result.fills[1].date, third);
assert_eq!(result.fills[1].price, 20.0);
assert_eq!(result.fills[1].quantity, 1_400);
assert_eq!(result.fills[1].decision_date, Some(second));
}
#[test]
fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() {
let first = d(2025, 1, 2);
+1
View File
@@ -125,6 +125,7 @@ pub struct FillEvent {
pub gross_amount: f64,
pub commission: f64,
pub stamp_tax: f64,
pub transfer_fee: f64,
pub net_cash_flow: f64,
pub reason: String,
}
+1
View File
@@ -837,6 +837,7 @@ impl FuturesAccountState {
gross_amount: notional,
commission: intent.transaction_cost.max(0.0),
stamp_tax: 0.0,
transfer_fee: 0.0,
net_cash_flow: cash_delta,
reason: format!(
"{} direction={} effect={}",
+6 -4
View File
@@ -52,8 +52,9 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use platform_expr_strategy::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformSelectionQuotePlan,
PlatformTradeAction, PlatformUniverseActionKind,
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
PlatformUniverseActionKind,
};
pub use platform_runtime_schema::{
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
@@ -66,8 +67,9 @@ pub use platform_strategy_spec::{
StrategyExpressionActionConfig, StrategyExpressionAllocationConfig,
StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig,
StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig,
StrategyExpressionTradingConfig, StrategyRuntimeEnvironment, StrategyRuntimeExpressions,
StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value,
StrategyExpressionTradingConfig, StrategyPortfolioDrawdownControlConfig,
StrategyRuntimeEnvironment, StrategyRuntimeExpressions, StrategyRuntimeSpec,
platform_expr_config_from_spec, platform_expr_config_from_value,
};
pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position};
pub use risk_control::{
+156 -19
View File
@@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Serialize};
use crate::engine::DailyEquityPoint;
use crate::events::FillEvent;
use crate::events::{AccountEvent, FillEvent};
use crate::portfolio::HoldingSummary;
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
@@ -47,6 +47,11 @@ pub struct BacktestMetrics {
pub cash_balance: f64,
pub unit_nav: f64,
pub initial_cash: f64,
/// Sum of external deposits (positive) and withdrawals (negative). This
/// is reported separately so callers cannot mistake a cash transfer for
/// trading performance.
#[serde(default)]
pub external_cash_flow_total: f64,
pub excess_win_rate: f64,
pub monthly_sharpe: f64,
pub monthly_volatility: f64,
@@ -56,6 +61,7 @@ pub fn compute_backtest_metrics(
equity_curve: &[DailyEquityPoint],
fills: &[FillEvent],
daily_holdings: &[HoldingSummary],
account_events: &[AccountEvent],
initial_cash: f64,
) -> BacktestMetrics {
let Some(first_point) = equity_curve.first() else {
@@ -81,12 +87,28 @@ pub fn compute_backtest_metrics(
} else {
first_point.benchmark_close
};
let mut returns = Vec::with_capacity(equity_curve.len());
returns.push(pct_change(initial_cash, first_point.total_equity));
returns.extend(
let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite()
&& point.unit_nav > 0.0
&& (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12)
});
let portfolio_nav = if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point_nav(point, initial_cash))
.collect::<Vec<_>>()
} else {
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
};
let mut returns = Vec::with_capacity(portfolio_nav.len());
if let Some(first_nav) = portfolio_nav.first().copied() {
returns.push(pct_change(1.0, first_nav));
}
returns.extend(
portfolio_nav
.windows(2)
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
.map(|window| pct_change(window[0], window[1])),
);
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
@@ -107,15 +129,12 @@ pub fn compute_backtest_metrics(
last_point.benchmark_close / benchmark_start
};
let benchmark_cumulative_return = benchmark_net_value - 1.0;
let total_return = if initial_cash.abs() < f64::EPSILON {
0.0
} else {
(last_point.total_equity / initial_cash) - 1.0
};
let final_nav = portfolio_nav.last().copied().unwrap_or(1.0);
let total_return = final_nav - 1.0;
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
total_return
} else {
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0
};
let excess_return = total_return - benchmark_cumulative_return;
let annual_return = annualize_return(total_return, trade_days);
@@ -132,10 +151,7 @@ pub fn compute_backtest_metrics(
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
let equity_nav = equity_curve
.iter()
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
.collect::<Vec<_>>();
let equity_nav = portfolio_nav;
let benchmark_nav_series = equity_curve
.iter()
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
@@ -154,8 +170,7 @@ pub fn compute_backtest_metrics(
let win_rate = ratio(winning_days, returns.len());
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
let monthly_portfolio_returns =
group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity);
let monthly_portfolio_returns = group_monthly_returns_from_values(equity_curve, &equity_nav);
let monthly_benchmark_returns =
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
let monthly_excess_returns = monthly_portfolio_returns
@@ -257,14 +272,30 @@ pub fn compute_backtest_metrics(
average_daily_turnover,
total_assets: last_point.total_equity,
cash_balance: last_point.cash,
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
unit_nav: final_nav,
initial_cash,
external_cash_flow_total: if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point.external_cash_flow)
.sum()
} else {
external_flow_total_from_events(account_events)
},
excess_win_rate,
monthly_sharpe,
monthly_volatility,
}
}
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
point.unit_nav
} else {
safe_div(point.total_equity, initial_cash, 1.0)
}
}
fn pct_change(previous: f64, current: f64) -> f64 {
if previous.abs() < f64::EPSILON {
0.0
@@ -384,6 +415,80 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
(max_drawdown, max_duration)
}
fn flow_neutral_nav_series(
equity_curve: &[DailyEquityPoint],
account_events: &[AccountEvent],
initial_cash: f64,
) -> Vec<f64> {
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::new();
for event in account_events {
if !(event.note.starts_with("deposit_withdraw amount=")
|| event.note.starts_with("deposit_withdraw_settled amount="))
{
continue;
}
*external_flow_by_date.entry(event.date).or_default() +=
event.cash_after - event.cash_before;
}
let mut units = initial_cash;
let mut previous_equity = initial_cash;
let mut navs = Vec::with_capacity(equity_curve.len());
for point in equity_curve {
let unit_nav_before_flow = safe_div(previous_equity, units, 1.0);
let external_flow = external_flow_by_date
.get(&point.date)
.copied()
.unwrap_or_default();
if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() {
units += external_flow / unit_nav_before_flow;
}
let unit_nav = safe_div(point.total_equity, units, 0.0);
navs.push(unit_nav);
previous_equity = point.total_equity;
}
navs
}
fn external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 {
account_events
.iter()
.filter(|event| {
event.note.starts_with("deposit_withdraw amount=")
|| event.note.starts_with("deposit_withdraw_settled amount=")
})
.map(|event| event.cash_after - event.cash_before)
.sum()
}
fn group_monthly_returns_from_values(
equity_curve: &[DailyEquityPoint],
values: &[f64],
) -> Vec<f64> {
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
let mut previous_value = 1.0;
for (point, value) in equity_curve.iter().zip(values.iter().copied()) {
let key = (point.date.year(), point.date.month());
month_first.entry(key).or_insert(previous_value);
month_last.insert(key, value);
previous_value = value;
}
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
keys.sort_unstable();
keys.into_iter()
.filter_map(|key| {
let first = month_first.get(&key).copied().unwrap_or_default();
let last = month_last.get(&key).copied().unwrap_or_default();
if first.abs() < f64::EPSILON {
None
} else {
Some((last / first) - 1.0)
}
})
.collect()
}
fn group_monthly_returns<F>(
equity_curve: &[DailyEquityPoint],
initial_value: f64,
@@ -486,6 +591,8 @@ mod tests {
cash: total_equity,
market_value: 0.0,
total_equity,
external_cash_flow: 0.0,
unit_nav: total_equity / 100.0,
benchmark_close,
benchmark_prev_close,
notes: String::new(),
@@ -499,8 +606,38 @@ mod tests {
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
];
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0);
let expected = 7595.285 / 5957.717 - 1.0;
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
}
#[test]
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
let curve = vec![
equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint {
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0,
market_value: 0.0,
total_equity: 220.0,
external_cash_flow: 100.0,
unit_nav: 1.1,
benchmark_close: 100.0,
benchmark_prev_close: 100.0,
notes: String::new(),
diagnostics: String::new(),
},
];
let events = vec![AccountEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash_before: 100.0,
cash_after: 200.0,
total_equity: 200.0,
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
}];
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0);
assert!((metrics.total_return - 0.1).abs() < 1e-12);
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
}
}
File diff suppressed because it is too large Load Diff
@@ -67,6 +67,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
// day-level
"signal_close",
"benchmark_close",
"benchmark_signal_close",
"signal_ma5",
"signal_ma10",
"signal_ma20",
@@ -227,6 +228,8 @@ const RUNTIME_HELPER_FUNCTIONS: &[&str] = &[
"day_factor",
"rolling_mean",
"rolling_mean_current",
"rolling_max_current",
"rolling_return_stddev_current",
"ma",
"sma",
"vma",
@@ -326,6 +329,7 @@ mod tests {
for required in [
"signal_close",
"benchmark_close",
"benchmark_signal_close",
"close",
"avg_cost",
"current_price",
+386 -174
View File
@@ -7,8 +7,9 @@ use serde_json::Value;
use crate::{
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
RebalanceCashMode, ScheduleTimeRule, SlippageModel,
};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -66,6 +67,8 @@ pub struct StrategyRebalanceSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyExecutionSpec {
#[serde(default)]
pub frequency: Option<String>,
#[serde(default, alias = "matching_type")]
pub matching_type: Option<String>,
#[serde(default, alias = "slippage_model")]
@@ -87,6 +90,8 @@ pub struct StrategyExecutionSpec {
alias = "minCommission"
)]
pub minimum_commission: Option<f64>,
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
pub transfer_fee_rate: Option<f64>,
#[serde(default, alias = "stamp_tax_rate")]
pub stamp_tax_rate: Option<f64>,
#[serde(default, alias = "stamp_tax_rate_before_change")]
@@ -114,12 +119,10 @@ pub struct StrategyExecutionSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyEngineConfig {
#[serde(default)]
pub frequency: Option<String>,
#[serde(default)]
pub template_id: Option<String>,
#[serde(default, alias = "profile_name")]
pub profile_name: Option<String>,
#[serde(default, alias = "compatibility_profile")]
pub compatibility_profile: Option<String>,
#[serde(default, alias = "benchmark_symbol")]
pub benchmark_symbol: Option<String>,
#[serde(default, alias = "signal_symbol")]
@@ -161,6 +164,8 @@ pub struct StrategyEngineConfig {
alias = "minCommission"
)]
pub minimum_commission: Option<f64>,
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
pub transfer_fee_rate: Option<f64>,
#[serde(default, alias = "stamp_tax_rate")]
pub stamp_tax_rate: Option<f64>,
#[serde(default, alias = "stamp_tax_rate_before_change")]
@@ -281,6 +286,8 @@ pub struct StrategyRiskPolicySpec {
alias = "minCommission"
)]
pub minimum_commission: Option<f64>,
#[serde(default, alias = "transfer_fee_rate", alias = "transferFeeRate")]
pub transfer_fee_rate: Option<f64>,
#[serde(default, alias = "stamp_tax_rate")]
pub stamp_tax_rate: Option<f64>,
#[serde(default, alias = "stamp_tax_rate_before_change")]
@@ -342,6 +349,7 @@ const RISK_POLICY_VALUE_ALIAS_GROUPS: &[(&str, &[&str])] = &[
"minimumCommission",
&["minimum_commission", "min_commission", "minCommission"],
),
("transferFeeRate", &["transfer_fee_rate"]),
("stampTaxRate", &["stamp_tax_rate"]),
(
"stampTaxRateBeforeChange",
@@ -639,6 +647,8 @@ pub struct StrategyExpressionSelectionConfig {
#[serde(default)]
pub limit_expr: Option<String>,
#[serde(default)]
pub candidate_limit_expr: Option<String>,
#[serde(default)]
pub market_cap_field: Option<String>,
#[serde(default)]
pub market_cap_lower_expr: Option<String>,
@@ -661,9 +671,32 @@ pub struct StrategyExpressionRiskConfig {
#[serde(default)]
pub exposure_expr: Option<String>,
#[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)]
pub stop_loss_expr: Option<String>,
#[serde(default)]
pub take_profit_expr: Option<String>,
#[serde(
default,
alias = "referencePriceMode",
alias = "stop_take_reference_price_mode"
)]
pub stop_take_reference_price_mode: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrategyPortfolioDrawdownControlConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub drawdown_trigger: Option<f64>,
#[serde(default)]
pub floor_exposure: Option<f64>,
#[serde(default)]
pub cooldown_trading_days: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -691,6 +724,18 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)]
pub daily_top_up: Option<bool>,
#[serde(default)]
pub daily_position_target_adjust: Option<bool>,
#[serde(default)]
pub target_portfolio_daily: Option<bool>,
#[serde(default)]
pub rebalance_existing_positions: Option<bool>,
#[serde(default)]
pub hold_until_exit: Option<bool>,
#[serde(default)]
pub selection_buffer_multiple: Option<f64>,
#[serde(default)]
pub daily_replacement_limit: Option<usize>,
#[serde(default)]
pub retry_empty_rebalance: Option<bool>,
#[serde(default)]
pub weak_market_shrink_overweight_threshold: Option<f64>,
@@ -773,6 +818,7 @@ pub fn platform_expr_config_from_value(
return platform_expr_config_from_spec(strategy_id, signal_symbol, None)
.map_err(platform_config_error);
}
reject_removed_compatibility_fields(value).map_err(platform_config_error)?;
let mut value = value.clone();
normalize_risk_policy_aliases_in_value(&mut value).map_err(platform_config_error)?;
let spec = serde_json::from_value::<StrategyRuntimeSpec>(value)?;
@@ -780,6 +826,40 @@ pub fn platform_expr_config_from_value(
.map_err(platform_config_error)
}
fn reject_removed_compatibility_fields(value: &Value) -> Result<(), String> {
const SECTION_NAMES: [&str; 3] = ["engineConfig", "engine_config", "execution"];
const FIELD_NAMES: [&str; 4] = [
"compatibilityProfile",
"compatibility_profile",
"compatProfile",
"compat_profile",
];
for section_name in SECTION_NAMES {
let Some(section) = value.get(section_name).and_then(Value::as_object) else {
continue;
};
if let Some(field_name) = FIELD_NAMES
.iter()
.find(|field_name| section.contains_key(**field_name))
{
return Err(format!(
"{section_name}.{field_name} has been removed; configure matching, risk, fees and scheduling explicitly"
));
}
if section_name != "execution" {
if let Some(field_name) = ["profileName", "profile_name"]
.iter()
.find(|field_name| section.contains_key(**field_name))
{
return Err(format!(
"{section_name}.{field_name} has been removed; configure matching, risk, fees and scheduling explicitly"
));
}
}
}
Ok(())
}
fn platform_config_error(message: String) -> serde_json::Error {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
@@ -811,16 +891,29 @@ fn parse_policy_date(value: Option<&str>) -> Option<NaiveDate> {
.ok()
}
fn is_aiquant_profile(value: Option<&str>) -> bool {
value
.map(|item| item.trim().to_ascii_lowercase().replace('-', "_"))
.is_some_and(|item| item == "aiquant" || item == "aiquant_rqalpha" || item == "rqalpha")
fn parse_stop_take_reference_price_mode(
value: &str,
) -> Result<PlatformStopTakeReferencePriceMode, String> {
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"position_cost_basis" | "position_cost" | "execution_cost_basis" => {
Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis)
}
"signal_day_post_adjusted_close"
| "signal_post_adjusted_close"
| "model_signal_post_adjusted_close" => {
Ok(PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose)
}
other => Err(format!(
"runtimeExpressions.risk.stopTakeReferencePriceMode unsupported: {other}"
)),
}
}
fn apply_cost_overrides(
cfg: &mut PlatformExprStrategyConfig,
commission_rate: Option<f64>,
minimum_commission: Option<f64>,
transfer_fee_rate: Option<f64>,
stamp_tax_rate: Option<f64>,
stamp_tax_rate_before_change: Option<f64>,
stamp_tax_rate_after_change: Option<f64>,
@@ -834,6 +927,9 @@ fn apply_cost_overrides(
cfg.minimum_commission = Some(value);
cfg.risk_config.trading_constraints.minimum_commission = value;
}
if let Some(value) = valid_non_negative(transfer_fee_rate) {
cfg.risk_config.trading_constraints.transfer_fee_rate = value;
}
if let Some(value) = valid_non_negative(stamp_tax_rate) {
cfg.stamp_tax_rate_before_change = Some(value);
cfg.stamp_tax_rate_after_change = Some(value);
@@ -989,6 +1085,7 @@ fn apply_risk_policy_overrides(
cfg,
policy.commission_rate,
policy.minimum_commission,
policy.transfer_fee_rate,
policy.stamp_tax_rate,
policy.stamp_tax_rate_before_change,
policy.stamp_tax_rate_after_change,
@@ -1001,6 +1098,22 @@ fn normalize_model_name(value: &str) -> String {
value.trim().to_ascii_lowercase().replace('-', "_")
}
fn normalize_slippage_model_name(value: &str) -> String {
match normalize_model_name(value).as_str() {
"percent"
| "percentage"
| "rate"
| "ratio"
| "price_percent"
| "price_percentage"
| "price_rate"
| "price_ratio_slippage"
| "priceratioslippage" => "price_ratio".to_string(),
"dynamic_volume_volatility" => "dynamic".to_string(),
other => other.to_string(),
}
}
fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, String> {
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
return Ok(None);
@@ -1047,7 +1160,7 @@ fn parse_slippage_model(
let volatility_coefficient = valid_non_negative(volatility_coefficient);
let max_value = valid_non_negative(max_value);
let model = model
.map(normalize_model_name)
.map(normalize_slippage_model_name)
.filter(|item| !item.is_empty())
.unwrap_or_else(|| {
if value.is_some_and(|item| item > 0.0) {
@@ -1062,13 +1175,11 @@ fn parse_slippage_model(
"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" | "dynamic_volume_volatility" => {
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),
)))
}
"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,
}
}
@@ -1110,9 +1221,10 @@ fn apply_execution_behavior_overrides(
cfg.slippage_model = parsed;
}
}
if let Some(enabled) = strict_value_budget {
cfg.strict_value_budget = enabled;
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(
@@ -1395,6 +1507,7 @@ pub fn platform_expr_config_from_spec(
&mut cfg,
engine.commission_rate,
engine.minimum_commission,
engine.transfer_fee_rate,
engine.stamp_tax_rate,
engine.stamp_tax_rate_before_change,
engine.stamp_tax_rate_after_change,
@@ -1508,6 +1621,13 @@ pub fn platform_expr_config_from_spec(
{
cfg.selection_limit_expr = expr.clone();
}
if let Some(expr) = selection
.candidate_limit_expr
.as_ref()
.filter(|value| !value.trim().is_empty())
{
cfg.selection_candidate_limit_expr = expr.clone();
}
if let Some(field) = selection
.market_cap_field
.as_ref()
@@ -1562,6 +1682,43 @@ pub fn platform_expr_config_from_spec(
expr.clone()
};
}
if let Some(control) = risk.portfolio_drawdown_control.as_ref()
&& control.enabled.unwrap_or(true)
{
let drawdown_trigger = control
.drawdown_trigger
.filter(|value| value.is_finite() && *value > 0.0 && *value < 1.0)
.ok_or_else(|| {
"runtimeExpressions.risk.portfolioDrawdownControl.drawdownTrigger must be between 0 and 1"
.to_string()
})?;
let floor_exposure = control
.floor_exposure
.filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
.ok_or_else(|| {
"runtimeExpressions.risk.portfolioDrawdownControl.floorExposure must be between 0 and 1"
.to_string()
})?;
let cooldown_trading_days = control
.cooldown_trading_days
.filter(|value| *value > 0)
.ok_or_else(|| {
"runtimeExpressions.risk.portfolioDrawdownControl.cooldownTradingDays must be positive"
.to_string()
})?;
cfg.portfolio_drawdown_control = Some(PlatformPortfolioDrawdownControlConfig {
mode: control
.mode
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("portfolio_drawdown")
.to_string(),
drawdown_trigger,
floor_exposure,
cooldown_trading_days,
});
}
if let Some(expr) = risk
.stop_loss_expr
.as_ref()
@@ -1576,6 +1733,14 @@ pub fn platform_expr_config_from_spec(
{
cfg.take_profit_expr = expr.clone();
}
if let Some(mode) = risk
.stop_take_reference_price_mode
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
cfg.stop_take_reference_price_mode = parse_stop_take_reference_price_mode(mode)?;
}
}
if let Some(ordering) = runtime_expr.ordering.as_ref() {
if let Some(rank_by) = ordering
@@ -1614,6 +1779,30 @@ pub fn platform_expr_config_from_spec(
if let Some(enabled) = trading.daily_top_up {
cfg.daily_top_up_enabled = enabled;
}
if let Some(enabled) = trading.daily_position_target_adjust {
cfg.daily_position_target_adjust_enabled = enabled;
}
if let Some(enabled) = trading.target_portfolio_daily {
cfg.target_portfolio_daily_enabled = enabled;
if enabled {
cfg.daily_position_target_adjust_enabled = true;
}
}
if let Some(enabled) = trading.rebalance_existing_positions {
cfg.rebalance_existing_positions = enabled;
}
if let Some(enabled) = trading.hold_until_exit {
cfg.hold_until_exit_enabled = enabled;
}
if let Some(multiple) = trading
.selection_buffer_multiple
.filter(|value| value.is_finite() && *value >= 1.0)
{
cfg.selection_buffer_multiple = multiple;
}
if let Some(limit) = trading.daily_replacement_limit {
cfg.daily_replacement_limit = limit;
}
if let Some(enabled) = trading.retry_empty_rebalance {
cfg.retry_empty_rebalance = enabled;
}
@@ -1770,57 +1959,16 @@ pub fn platform_expr_config_from_spec(
if !cfg.benchmark_symbol.trim().is_empty() {
cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None);
}
let aiquant_profile = spec.engine_config.as_ref().is_some_and(|engine| {
is_aiquant_profile(engine.profile_name.as_deref())
|| is_aiquant_profile(engine.compatibility_profile.as_deref())
});
if aiquant_profile {
cfg.aiquant_transaction_cost = true;
cfg.strict_value_budget = true;
if !cfg.universe_exclude.iter().any(|item| item == "bjse") {
cfg.universe_exclude.push("bjse".to_string());
}
let trading = spec
.runtime_expressions
.as_ref()
.and_then(|runtime_expr| runtime_expr.trading.as_ref());
if trading.and_then(|item| item.daily_top_up).is_none() {
cfg.daily_top_up_enabled = true;
}
if trading
.and_then(|item| item.retry_empty_rebalance)
.is_none()
{
cfg.retry_empty_rebalance = true;
}
}
let trade_times = spec_trade_times(spec);
if let Some(main_trade_time) = trade_times.last().copied() {
cfg.intraday_execution_time = Some(main_trade_time);
}
let delayed_limit_open_exit_explicit = spec
.runtime_expressions
.as_ref()
.and_then(|runtime_expr| runtime_expr.trading.as_ref())
.and_then(|trading| trading.delayed_limit_open_exit)
.is_some();
if aiquant_profile && !delayed_limit_open_exit_explicit && trade_times.len() > 1 {
let delayed_time = trade_times[0];
if trade_times
.last()
.copied()
.map(|main_time| main_time != delayed_time)
.unwrap_or(true)
{
cfg.delayed_limit_open_exit_enabled = true;
cfg.delayed_limit_open_exit_time = Some(delayed_time);
}
}
if let Some(execution) = spec.execution.as_ref() {
apply_cost_overrides(
&mut cfg,
execution.commission_rate,
execution.minimum_commission,
execution.transfer_fee_rate,
execution.stamp_tax_rate,
execution.stamp_tax_rate_before_change,
execution.stamp_tax_rate_after_change,
@@ -1847,16 +1995,7 @@ pub fn platform_expr_config_from_spec(
)?;
sync_quote_quantity_limit(&mut cfg);
}
if cfg.aiquant_transaction_cost
&& cfg
.minimum_commission
.is_some_and(|value| value.is_finite() && value <= 0.0)
{
cfg.minimum_commission = None;
}
if aiquant_profile {
cfg.strict_value_budget = true;
}
cfg.strict_value_budget = true;
Ok(cfg)
}
@@ -1891,7 +2030,15 @@ fn signal_rebalance_dates(rebalance: &StrategyRebalanceSpec) -> Option<BTreeSet<
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if frequency != "signal_dates" && frequency != "signal-dates" && frequency != "signal dates" {
if !matches!(
frequency.as_str(),
"signal_dates"
| "signal-dates"
| "signal dates"
| "daily_model_score_rank"
| "dynamic_model_score_rank"
| "model_rank_rotation"
) {
return None;
}
let dates = rebalance
@@ -2298,7 +2445,6 @@ mod tests {
"signalSymbol": "000852.SH",
"benchmark": { "instrumentId": "000852.SH" },
"universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] },
"engineConfig": { "profileName": "aiquant" },
"runtimeExpressions": {
"prelude": "let stocknum = 8;",
"selection": {
@@ -2332,16 +2478,12 @@ mod tests {
assert_eq!(cfg.signal_symbol, "000852.SH");
assert_eq!(cfg.selection_limit_expr, "stocknum");
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20");
assert_eq!(
cfg.universe_exclude,
["paused", "st", "kcb", "one_yuan", "bjse"]
);
assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
assert!(!cfg.rotation_enabled);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
assert!(!cfg.calendar_rebalance_interval);
assert!(cfg.aiquant_transaction_cost);
assert_eq!(cfg.explicit_actions.len(), 1);
assert_eq!(
cfg.explicit_action_stage,
@@ -2380,6 +2522,35 @@ mod tests {
assert_eq!(cfg.max_holding_days, Some(90));
}
#[test]
fn parses_lifecycle_target_portfolio_contract() {
let spec = serde_json::json!({
"runtimeExpressions": {
"selection": {
"limitExpr": "30",
"candidateLimitExpr": "50"
},
"trading": {
"dailyPositionTargetAdjust": true,
"targetPortfolioDaily": true,
"rebalanceExistingPositions": true,
"holdUntilExit": true,
"releaseSlotOnExitSignal": true
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.selection_limit_expr, "30");
assert_eq!(cfg.selection_candidate_limit_expr, "50");
assert!(cfg.daily_position_target_adjust_enabled);
assert!(cfg.target_portfolio_daily_enabled);
assert!(cfg.rebalance_existing_positions);
assert!(cfg.hold_until_exit_enabled);
assert!(cfg.release_slot_on_exit_signal);
}
#[test]
fn parses_signal_dates_rebalance_into_platform_config() {
let spec = serde_json::json!({
@@ -2402,6 +2573,26 @@ mod tests {
assert_eq!(cfg.signal_rebalance_dates.len(), 2);
}
#[test]
fn parses_dynamic_model_score_dates_into_platform_config() {
let spec = serde_json::json!({
"rebalance": {
"frequency": "daily_model_score_rank",
"dates": ["2025-11-10", "2025-11-17"]
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(
cfg.signal_rebalance_dates,
BTreeSet::from([
NaiveDate::from_ymd_opt(2025, 11, 10).unwrap(),
NaiveDate::from_ymd_opt(2025, 11, 17).unwrap(),
])
);
}
#[test]
fn parses_execution_cost_overrides_into_platform_config() {
let spec = serde_json::json!({
@@ -2413,14 +2604,12 @@ mod tests {
"stampTaxChangeDate": "2024-01-02"
},
"engineConfig": {
"profileName": "aiquant",
"commissionRate": 0.0008
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
assert_eq!(cfg.commission_rate, Some(0.0003));
assert_eq!(cfg.minimum_commission, Some(5.0));
assert_eq!(cfg.stamp_tax_rate_before_change, Some(0.0005));
@@ -2703,11 +2892,10 @@ mod tests {
"strictValueBudget": true
},
"engineConfig": {
"profileName": "aiquant",
"matchingType": "current_bar_close",
"slippageModel": "none",
"slippageValue": 0.0,
"strictValueBudget": false
"strictValueBudget": true
}
});
@@ -2718,6 +2906,20 @@ mod tests {
assert!(cfg.strict_value_budget);
}
#[test]
fn parses_percent_slippage_alias_into_platform_config() {
let spec = serde_json::json!({
"execution": {
"slippageModel": "percent",
"slippageValue": 0.001
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.slippage_model, SlippageModel::PriceRatio(0.001));
}
#[test]
fn parses_rebalance_cash_mode_and_forces_minute_to_actual_sequence() {
let spec = serde_json::json!({
@@ -2867,74 +3069,9 @@ mod tests {
);
}
#[test]
fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant"
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
assert!(cfg.strict_value_budget);
let explicit_off = serde_json::json!({
"engineConfig": {
"profileName": "aiquant"
},
"runtimeExpressions": {
"trading": {
"dailyTopUp": false,
"retryEmptyRebalance": false
}
}
});
let cfg = platform_expr_config_from_value("", "", &explicit_off).expect("config");
assert!(!cfg.daily_top_up_enabled);
assert!(!cfg.retry_empty_rebalance);
}
#[test]
fn engine_config_profile_name_enables_aiquant_semantics() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant"
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
}
#[test]
fn engine_config_profile_name_accepts_aiquant_rqalpha_alias() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant_rqalpha"
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.aiquant_transaction_cost);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
assert!(cfg.strict_value_budget);
}
#[test]
fn runtime_expressions_infer_ma_windows_from_literal_strategy_logic() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant"
},
"runtimeExpressions": {
"selection": {
"stockFilterExpr": "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30)"
@@ -2992,7 +3129,6 @@ mod tests {
"instrumentId": "932000.CSI"
},
"engineConfig": {
"profileName": "aiquant",
"indexThrottle": {
"shortDays": 10,
"longDays": 30,
@@ -3019,9 +3155,8 @@ mod tests {
}
#[test]
fn parses_daily_schedule_time_for_aiquant_execution_quotes() {
fn parses_daily_schedule_time_for_execution_quotes() {
let spec = serde_json::json!({
"engineConfig": { "profileName": "aiquant" },
"runtimeExpressions": {
"schedule": { "frequency": "daily", "time": "09:33" }
}
@@ -3035,13 +3170,11 @@ mod tests {
Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap())
);
assert!(!cfg.calendar_rebalance_interval);
assert!(cfg.aiquant_transaction_cost);
}
#[test]
fn parses_aiquant_rebalance_trade_times_for_delayed_limit_exit() {
fn multiple_trade_times_do_not_imply_delayed_limit_exit() {
let spec = serde_json::json!({
"engineConfig": { "profileName": "aiquant" },
"rebalance": { "tradeTimes": ["10:31", "10:40"] },
"runtimeExpressions": {
"schedule": { "frequency": "daily", "time": "10:40" }
@@ -3054,18 +3187,14 @@ mod tests {
cfg.intraday_execution_time,
Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap())
);
assert!(cfg.delayed_limit_open_exit_enabled);
assert_eq!(
cfg.delayed_limit_open_exit_time,
Some(NaiveTime::from_hms_opt(10, 31, 0).unwrap())
);
assert!(!cfg.delayed_limit_open_exit_enabled);
assert_eq!(cfg.delayed_limit_open_exit_time, None);
}
#[test]
fn parses_aiquant_compatibility_profile_for_delayed_limit_exit() {
fn rejects_removed_compatibility_profile() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "cn_a_microcap_v1",
"compatibilityProfile": "aiquant_rqalpha"
},
"rebalance": { "tradeTimes": ["09:31", "10:15"] },
@@ -3074,23 +3203,29 @@ mod tests {
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
let error = platform_expr_config_from_value("", "", &spec).expect_err("removed field");
assert!(
error
.to_string()
.contains("compatibilityProfile has been removed")
);
}
assert_eq!(
cfg.intraday_execution_time,
Some(NaiveTime::from_hms_opt(10, 15, 0).unwrap())
);
assert!(cfg.delayed_limit_open_exit_enabled);
assert_eq!(
cfg.delayed_limit_open_exit_time,
Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap())
);
#[test]
fn rejects_removed_engine_profile_name() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "aiquant"
}
});
let error = platform_expr_config_from_value("", "", &spec).expect_err("removed field");
assert!(error.to_string().contains("profileName has been removed"));
}
#[test]
fn parses_explicit_delayed_limit_open_exit() {
let spec = serde_json::json!({
"engineConfig": { "profileName": "aiquant" },
"runtimeExpressions": {
"schedule": { "frequency": "daily", "time": "10:40" },
"trading": {
@@ -3114,9 +3249,8 @@ mod tests {
}
#[test]
fn explicit_delayed_limit_open_exit_false_overrides_aiquant_trade_times() {
fn explicit_delayed_limit_open_exit_false_overrides_trade_times() {
let spec = serde_json::json!({
"engineConfig": { "profileName": "aiquant" },
"rebalance": { "tradeTimes": ["10:31", "10:40"] },
"runtimeExpressions": {
"schedule": { "frequency": "daily", "time": "10:40" },
@@ -3132,4 +3266,82 @@ mod tests {
assert!(!cfg.delayed_limit_open_exit_enabled);
assert_eq!(cfg.delayed_limit_open_exit_time, None);
}
#[test]
fn parses_portfolio_drawdown_control_into_platform_config() {
let spec = serde_json::json!({
"runtimeExpressions": {
"risk": {
"exposureExpr": "1.0",
"portfolioDrawdownControl": {
"enabled": true,
"mode": "portfolio_dd3_floor10_cool30",
"drawdownTrigger": 0.03,
"floorExposure": 0.10,
"cooldownTradingDays": 30
}
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
let control = cfg.portfolio_drawdown_control.expect("drawdown control");
assert_eq!(control.mode, "portfolio_dd3_floor10_cool30");
assert_eq!(control.drawdown_trigger, 0.03);
assert_eq!(control.floor_exposure, 0.10);
assert_eq!(control.cooldown_trading_days, 30);
}
#[test]
fn rejects_invalid_portfolio_drawdown_control() {
let spec = serde_json::json!({
"runtimeExpressions": {
"risk": {
"portfolioDrawdownControl": {
"drawdownTrigger": 0.0,
"floorExposure": 0.10,
"cooldownTradingDays": 30
}
}
}
});
let error = platform_expr_config_from_value("", "", &spec).expect_err("invalid trigger");
assert!(error.to_string().contains("drawdownTrigger"));
}
#[test]
fn parses_signal_day_post_adjusted_stop_take_reference_price_mode() {
let spec = serde_json::json!({
"runtimeExpressions": {
"risk": {
"stopLossExpr": "0.92",
"takeProfitExpr": "1.16",
"stopTakeReferencePriceMode": "signal_day_post_adjusted_close"
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(
cfg.stop_take_reference_price_mode,
PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose
);
}
#[test]
fn rejects_unknown_stop_take_reference_price_mode() {
let spec = serde_json::json!({
"runtimeExpressions": {
"risk": {
"stopTakeReferencePriceMode": "future_price"
}
}
});
let error = platform_expr_config_from_value("", "", &spec).expect_err("invalid mode");
assert!(error.to_string().contains("stopTakeReferencePriceMode"));
}
}
+203 -18
View File
@@ -82,6 +82,8 @@ impl Position {
return;
}
let previous_quantity = self.quantity;
let previous_average_cost = self.average_cost;
self.lots.push(PositionLot {
acquired_date: date,
quantity,
@@ -93,7 +95,18 @@ impl Position {
self.day_trade_quantity_delta += quantity as i32;
self.day_buy_quantity += quantity;
self.day_buy_value += execution_price * quantity as f64;
self.recalculate_average_cost();
if previous_quantity > 0
&& previous_average_cost.is_finite()
&& previous_average_cost > 0.0
&& execution_price.is_finite()
&& execution_price > 0.0
{
self.average_cost = (previous_average_cost * previous_quantity as f64
+ execution_price * quantity as f64)
/ self.quantity as f64;
} else {
self.recalculate_average_cost();
}
self.refresh_day_pnl();
}
@@ -259,7 +272,11 @@ impl Position {
}
if let Some(lot) = self.lots.last_mut() {
lot.price += cost / quantity as f64;
self.recalculate_average_cost();
if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost += cost / self.quantity as f64;
} else {
self.recalculate_average_cost();
}
}
self.day_trade_cost += cost;
self.refresh_day_pnl();
@@ -377,7 +394,11 @@ impl Position {
self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio;
self.recalculate_average_cost();
if self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost /= ratio;
} else {
self.recalculate_average_cost();
}
self.day_split_ratio *= ratio;
self.refresh_day_pnl();
self.quantity as i32 - old_quantity as i32
@@ -411,6 +432,11 @@ pub struct PortfolioState {
initial_cash: f64,
units: f64,
cash: f64,
/// Cumulative external cash flow (deposits positive, withdrawals negative).
/// Trading proceeds, dividends, fees and financing are deliberately not
/// included. The value is used by the engine to build a cash-flow-neutral
/// equity curve and is not a return measure itself.
external_cash_flow_total: f64,
cash_liabilities: f64,
management_fee_rate: f64,
management_fees: f64,
@@ -444,6 +470,7 @@ impl PortfolioState {
initial_cash,
units: initial_cash,
cash: initial_cash,
external_cash_flow_total: 0.0,
cash_liabilities: 0.0,
management_fee_rate: 0.0,
management_fees: 0.0,
@@ -455,7 +482,9 @@ impl PortfolioState {
}
pub fn starting_cash(&self) -> f64 {
self.units
// Keep the configured opening capital stable. External flows change
// `units`, not the meaning of this reporting field.
self.initial_cash
}
pub fn initial_cash(&self) -> f64 {
@@ -470,6 +499,10 @@ impl PortfolioState {
self.cash
}
pub fn external_cash_flow_total(&self) -> f64 {
self.external_cash_flow_total
}
pub fn cash_liabilities(&self) -> f64 {
self.cash_liabilities
}
@@ -528,15 +561,17 @@ impl PortfolioState {
if !amount.is_finite() {
return Err("deposit_withdraw amount must be finite".to_string());
}
if amount < 0.0 && self.cash + amount < -1e-6 {
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
let available_cash = self.cash - self.pending_withdrawal_total();
return Err(format!(
"insufficient cash for withdrawal amount={:.2} cash={:.2}",
amount, self.cash
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}",
amount, available_cash
));
}
let unit_net_value = self.unit_net_value();
self.cash += amount;
self.external_cash_flow_total += amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
Ok(())
}
@@ -550,10 +585,11 @@ impl PortfolioState {
if !amount.is_finite() {
return Err("deposit_withdraw amount must be finite".to_string());
}
if amount < 0.0 && self.cash + amount < -1e-6 {
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
let available_cash = self.cash - self.pending_withdrawal_total();
return Err(format!(
"insufficient cash for scheduled withdrawal amount={:.2} cash={:.2}",
amount, self.cash
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
amount, available_cash
));
}
self.pending_cash_flows.push(PendingCashFlow {
@@ -566,27 +602,74 @@ impl PortfolioState {
Ok(())
}
pub fn settle_pending_cash_flows(&mut self, date: NaiveDate) -> Vec<PendingCashFlow> {
let mut settled = Vec::new();
pub fn settle_pending_cash_flows(
&mut self,
date: NaiveDate,
) -> Result<Vec<PendingCashFlow>, String> {
let mut due = Vec::new();
let mut pending = Vec::new();
for flow in std::mem::take(&mut self.pending_cash_flows) {
if flow.payable_date <= date {
let unit_net_value = self.unit_net_value();
self.cash += flow.amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
settled.push(flow);
due.push(flow);
} else {
pending.push(flow);
}
}
// A delayed withdrawal must not be allowed to make the account
// negative after trades on an earlier day. Validate the complete due
// batch before mutating either cash or the pending queue so a failed
// settlement is atomic and can be diagnosed/retried safely.
let incoming = due
.iter()
.filter(|flow| flow.amount > 0.0)
.map(|flow| flow.amount)
.sum::<f64>();
let outgoing = due
.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| flow.amount)
.sum::<f64>();
if self.cash + incoming + outgoing < -1e-6 {
self.pending_cash_flows = due.into_iter().chain(pending).collect();
self.pending_cash_flows
.sort_by_key(|flow| flow.payable_date);
return Err(format!(
"insufficient cash to settle delayed cash flows on {date}: cash={:.2} net_due={:.2}",
self.cash,
incoming + outgoing
));
}
// There is no sub-day ordering in the strategy contract for flows
// sharing a payable date. Apply deposits first, then withdrawals, so
// a same-day net-zero batch is deterministic and never fails merely
// because a withdrawal happened to be listed first.
due.sort_by_key(|flow| (flow.payable_date, flow.amount < 0.0));
let mut settled = Vec::with_capacity(due.len());
for flow in due {
let unit_net_value = self.unit_net_value();
self.cash += flow.amount;
self.external_cash_flow_total += flow.amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
settled.push(flow);
}
self.pending_cash_flows = pending;
settled
Ok(settled)
}
pub fn pending_cash_flows(&self) -> &[PendingCashFlow] {
&self.pending_cash_flows
}
pub fn pending_withdrawal_total(&self) -> f64 {
self.pending_cash_flows
.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| -flow.amount)
.sum()
}
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
if !amount.is_finite() {
return Err("finance_repay amount must be finite".to_string());
@@ -677,6 +760,18 @@ impl PortfolioState {
) -> Result<(), DataSetError> {
let day_sold_symbols = self.day_sold_symbols.clone();
for position in self.positions.values_mut() {
let current_market_missing = data.market(date, &position.symbol).is_none();
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;
position.refresh_day_pnl();
continue;
}
let sold_today =
position.sold_quantity() > 0 || day_sold_symbols.contains(&position.symbol);
if same_day_buy_close_mark_at_fill
@@ -844,6 +939,7 @@ impl PortfolioState {
let old_quantity = old_position.quantity;
let last_price = old_position.last_price;
let old_average_cost = old_position.average_cost;
let realized_pnl = old_position.realized_pnl;
let realized_entry_pnl = old_position.realized_entry_pnl;
let mut converted_lots = old_position
@@ -879,6 +975,8 @@ impl PortfolioState {
.positions
.entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity;
let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
successor.realized_pnl += realized_pnl;
@@ -886,7 +984,30 @@ impl PortfolioState {
if converted_last_price > 0.0 {
successor.last_price = converted_last_price;
}
successor.recalculate_average_cost();
let converted_average_cost = if old_average_cost.is_finite()
&& old_average_cost > 0.0
&& ratio.is_finite()
&& ratio > 0.0
{
Some(old_average_cost / ratio)
} else {
None
};
if let Some(converted_average_cost) = converted_average_cost {
if successor_quantity_before > 0
&& successor_average_cost_before.is_finite()
&& successor_average_cost_before > 0.0
{
successor.average_cost = (successor_average_cost_before
* successor_quantity_before as f64
+ converted_average_cost * converted_quantity as f64)
/ successor.quantity as f64;
} else {
successor.average_cost = converted_average_cost;
}
} else {
successor.recalculate_average_cost();
}
successor.refresh_day_pnl();
Some(SuccessorConversionOutcome {
@@ -982,6 +1103,25 @@ mod tests {
assert!((position.average_cost - average_cost_before).abs() < 1e-12);
}
#[test]
fn buy_after_partial_sell_continues_moving_average_cost_basis() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("300405.SZ");
position.buy(date, 100, 10.0);
position.buy(date, 100, 5.0);
assert!((position.average_cost - 7.5).abs() < 1e-12);
position.sell(100, 6.0).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_cost - 7.5).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
position.buy(date, 100, 5.0);
assert_eq!(position.quantity, 200);
assert!((position.average_cost - 6.25).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
}
#[test]
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
@@ -1505,6 +1645,51 @@ mod tests {
assert!((portfolio.total_returns() - (portfolio.unit_net_value() - 1.0)).abs() < 1e-6);
assert_eq!(portfolio.cash_receivables().len(), 0);
}
#[test]
fn external_cash_flow_rebases_units_without_changing_nav() {
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.deposit_withdraw(5_000.0)
.expect("deposit should settle");
assert!((portfolio.cash() - 15_000.0).abs() < 1e-6);
assert!((portfolio.units() - 15_000.0).abs() < 1e-6);
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
assert!((portfolio.external_cash_flow_total() - 5_000.0).abs() < 1e-6);
portfolio
.deposit_withdraw(-2_000.0)
.expect("withdrawal should settle");
assert!((portfolio.cash() - 13_000.0).abs() < 1e-6);
assert!((portfolio.units() - 13_000.0).abs() < 1e-6);
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
assert!((portfolio.external_cash_flow_total() - 3_000.0).abs() < 1e-6);
}
#[test]
fn delayed_withdrawals_are_reserved_and_settled_atomically() {
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.schedule_deposit_withdraw(date, -8_000.0, "first")
.expect("first withdrawal should reserve cash");
assert!((portfolio.pending_withdrawal_total() - 8_000.0).abs() < 1e-6);
assert!(
portfolio
.schedule_deposit_withdraw(date, -3_000.0, "overcommit")
.is_err()
);
// A strategy cannot spend the reserved cash by scheduling a second
// withdrawal; settlement remains safe even if earlier trading reduced
// the current cash balance.
portfolio.apply_cash_delta(-3_000.0);
let error = portfolio
.settle_pending_cash_flows(date)
.expect_err("settlement must reject an underfunded withdrawal batch");
assert!(error.contains("insufficient cash"));
assert_eq!(portfolio.pending_cash_flows().len(), 1);
assert!((portfolio.cash() - 7_000.0).abs() < 1e-6);
}
}
#[derive(Debug, Clone, Serialize)]
+57 -1
View File
@@ -82,6 +82,7 @@ pub struct TradingConstraintConfig {
pub liquidity_limit_enabled: bool,
pub commission_rate: f64,
pub minimum_commission: f64,
pub transfer_fee_rate: f64,
pub stamp_tax_rate_before_change: f64,
pub stamp_tax_rate_after_change: f64,
pub stamp_tax_change_date: NaiveDate,
@@ -95,6 +96,7 @@ impl Default for TradingConstraintConfig {
liquidity_limit_enabled: true,
commission_rate: 0.0003,
minimum_commission: 5.0,
transfer_fee_rate: 0.0,
stamp_tax_rate_before_change: 0.001,
stamp_tax_rate_after_change: 0.0005,
stamp_tax_change_date: NaiveDate::from_ymd_opt(2023, 8, 28)
@@ -199,7 +201,7 @@ impl ChinaAShareRiskControl {
let terminal_status = matches!(
status.as_str(),
"inactive" | "delisted" | "terminated" | "expired"
) || status.contains("delist");
);
if terminal_status && instrument.delisted_at.is_none() {
return Some("inactive_or_delisted");
}
@@ -223,6 +225,21 @@ impl ChinaAShareRiskControl {
Self::instrument_rejection_reason(instrument, date)
}
pub fn active_status_rejection_reason_with_config(
date: NaiveDate,
candidate: Option<&CandidateEligibility>,
instrument: Option<&Instrument>,
config: &FidcRiskControlConfig,
scope: RiskCheckScope,
) -> Option<&'static str> {
if let Some(reason) =
Self::instrument_rejection_reason_with_config(instrument, date, config, scope)
{
return Some(reason);
}
candidate.and_then(|candidate| candidate_active_status_rejection(candidate, config, scope))
}
pub fn selection_rejection_reason(
date: NaiveDate,
candidate: &CandidateEligibility,
@@ -750,6 +767,45 @@ mod tests {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
fn instrument(status: &str, delisted_at: Option<NaiveDate>) -> Instrument {
Instrument {
symbol: "002898.SZ".to_string(),
name: "赛隆退".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2017, 9, 12)),
delisted_at,
status: status.to_string(),
}
}
#[test]
fn delisting_period_status_is_not_terminal_without_delisted_date() {
let date = d(2025, 3, 20);
assert_eq!(
ChinaAShareRiskControl::instrument_rejection_reason(
Some(&instrument("delisting", None)),
date,
),
None
);
assert_eq!(
ChinaAShareRiskControl::instrument_rejection_reason(
Some(&instrument("delisted", None)),
date,
),
Some("inactive_or_delisted")
);
assert_eq!(
ChinaAShareRiskControl::instrument_rejection_reason(
Some(&instrument("delisting", Some(d(2026, 7, 20)))),
d(2026, 7, 20),
),
Some("inactive_or_delisted")
);
}
fn candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility {
date,
+5 -34
View File
@@ -315,7 +315,7 @@ impl StrategyContext<'_> {
let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::<f64>();
let transaction_cost = fills
.iter()
.map(|fill| fill.commission + fill.stamp_tax)
.map(|fill| fill.commission + fill.stamp_tax + fill.transfer_fee)
.sum::<f64>();
let avg_price = if filled_quantity == 0 {
0.0
@@ -1655,6 +1655,7 @@ pub struct OmniMicroCapConfig {
pub stock_long_ma_days: usize,
pub stock_volume_short_ma_days: usize,
pub stock_volume_long_ma_days: usize,
pub stock_volume_filter_enabled: bool,
pub rsi_rate: f64,
pub trade_rate: f64,
pub stop_loss_ratio: f64,
@@ -1684,6 +1685,7 @@ impl OmniMicroCapConfig {
stock_long_ma_days: 20,
stock_volume_short_ma_days: 5,
stock_volume_long_ma_days: 60,
stock_volume_filter_enabled: true,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_ratio: 0.93,
@@ -1695,35 +1697,6 @@ impl OmniMicroCapConfig {
}
}
pub fn aiquant_v104() -> Self {
Self {
strategy_name: "aiquant-v1.0.4".to_string(),
refresh_rate: 120,
stocknum: 5,
xs: 4.0 / 500.0,
base_index_level: 2000.0,
base_cap_floor: 7.0,
cap_span: 10.0,
padding_ratio: 1.2,
min_padding: 29.5,
max_padding: 50.0,
benchmark_signal_symbol: "000852.SH".to_string(),
benchmark_short_ma_days: 5,
benchmark_long_ma_days: 20,
stock_short_ma_days: 5,
stock_mid_ma_days: 10,
stock_long_ma_days: 30,
stock_volume_short_ma_days: 5,
stock_volume_long_ma_days: 60,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_ratio: 0.92,
take_profit_ratio: 1.16,
skip_month_day_ranges: Vec::new(),
risk_config: FidcRiskControlConfig::default(),
}
}
fn in_skip_window(&self, date: NaiveDate) -> bool {
let year = date.year() as u32;
let month = date.month();
@@ -2433,10 +2406,7 @@ impl OmniMicroCapStrategy {
return false;
}
if self.config.strategy_name.contains("aiquant")
|| self.config.strategy_name.contains("AiQuant")
|| self.config.strategy_name.contains("omni")
{
if self.config.stock_volume_filter_enabled {
let Some(volume_ma5) = ctx.data.market_decision_volume_moving_average(
date,
symbol,
@@ -3018,6 +2988,7 @@ mod tests {
default_cfg.stock_short_ma_days = 1;
default_cfg.stock_mid_ma_days = 2;
default_cfg.stock_long_ma_days = 3;
default_cfg.stock_volume_filter_enabled = false;
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
let (default_selected, _) = default_strategy
.select_symbols(&ctx, dates[2], 0.0, 100.0)
+33 -18
View File
@@ -118,7 +118,7 @@ pub struct StrategyAiOptimizeRequest {
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
}
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 即视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同";
const PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT: &str = "收益验收合同:收益、回撤、年度收益、样本外区间及比较运算符只能来自用户目标、请求约束或不可变 candidate/promotion contract;不得注入 120% 或其他默认数值,也不得提高、降低或替换已经明确的门槛。没有明确数值合同时只做策略有效性、数据时序和风险审计,禁止声称收益已经达标;存在冻结合同时必须逐项按原运算符验证,不能只看总收益";
const DEFAULT_RISK_POLICY_DSL_PROMPT: &str = "reject_st_selection=false、reject_st_buy=true、reject_star_st_selection=false、reject_star_st_buy=true、reject_paused_selection=false、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=false、reject_inactive_buy=true、reject_inactive_sell=true、reject_new_listing_selection=false、reject_new_listing_buy=true、reject_kcb_selection=false、reject_kcb_buy=true、reject_bjse_selection=false、reject_bjse_buy=true、reject_one_yuan_selection=false、reject_one_yuan_buy=true、respect_allow_buy_sell=true、reject_upper_limit_selection=false、reject_lower_limit_selection=false、reject_upper_limit_buy=true、reject_lower_limit_sell=true、forbid_same_day_rebuy_after_sell=true、blacklist_enabled=true、allow_market_orders=true、live_trading_enabled=false、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_before_change=0.001、stamp_tax_rate_after_change=0.0005、stamp_tax_change_date=\"2023-08-28\"";
const DEFAULT_RISK_POLICY_DSL_CODE: &str = "reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=true, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\"";
@@ -135,7 +135,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
"AI 生成策略时只能输出完整 engine-script 代码,不输出 Markdown、解释、推理过程、JSON 包装或手册复述。".to_string(),
"表达式字段以运行时字段为准:市值使用 market_cap,流通市值使用 free_float_cap;不要在策略表达式中使用数据库原始字段 float_market_cap。".to_string(),
"任意窗口价格均线使用 rolling_mean(\"close\", n) 或 ma(\"close\", n),任意窗口均量使用 rolling_mean(\"volume\", n) 或 vma(n);不要使用未列出的 ma60、stock_ma60、signal_ma60 或 benchmark_ma60 变量。".to_string(),
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
"next_bar_open 会在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数;不得把执行日 open/high/low/close 当成下单前已知信息,也不得用 T+1 prev_close 或 T 日估算股数直接成交;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close。".to_string(),
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
],
@@ -250,7 +250,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualSection {
title: "risk.policy / risk.blacklist".to_string(),
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不写进 universe.exclude 或 filter.stock_expr。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
detail: "统一配置 FIDC 基础风控。risk.policy(...) 支持 reject_st_selection、reject_st_buy、reject_star_st_selection、reject_star_st_buy、reject_paused_selection、reject_paused_buy、reject_paused_sell、reject_inactive_selection、reject_inactive_buy、reject_inactive_sell、reject_new_listing_selection、reject_new_listing_buy、reject_kcb_selection、reject_kcb_buy、reject_bjse_selection、reject_bjse_buy、reject_one_yuan_selection、reject_one_yuan_buy、respect_allow_buy_sell、reject_upper_limit_selection、reject_lower_limit_selection、reject_upper_limit_buy、reject_lower_limit_sell、forbid_same_day_rebuy_after_sell、blacklist_enabled、allow_market_orders、live_trading_enabled、blacklisted_symbols、volume_limit_enabled、liquidity_limit_enabled、volume_percent、commission_rate、minimum_commission、stamp_tax_rate_before_change、stamp_tax_rate_after_change、stamp_tax_change_date 等命名参数;risk.blacklist([\"600000.SH\"]) 写策略级黑名单。框架默认的 ST、*ST、停牌、退市、科创、北交所、一元、涨跌停、同日卖出禁买、黑名单、成交量和费用等基础风控必须走 risk.policy 或运行态 RiskLimits,不能被转换器隐式写进 universe.exclude 或 filter.stock_expr;但源策略明确写出的业务选股排除属于策略本身,必须原样保留在 filter.stock_expr,并且不能反向修改冻结的 reject_*_selection 开关。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
},
ManualSection {
title: "corporate_actions.dividend_reinvestment".to_string(),
@@ -258,7 +258,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
},
ManualSection {
title: "execution.matching_type / execution.slippage".to_string(),
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\")current_bar_close 使用决策日当日 closenext_bar_open 使用决策日信号并在下一可交易日 open 撮合,禁止把执行日 open/high/low/close 解释为下单前已知数据;next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buysell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
detail: "设置回测全局撮合模式和滑点。日线回测只允许 execution.matching_type(\"current_bar_close\") 或 execution.matching_type(\"next_bar_open\")current_bar_close 使用决策日当日 closenext_bar_open 在 T 日收盘冻结目标金额或目标权益,并在下一可交易日按实际 open、滑点、手续费和证券数量步长重算股数,保证执行金额加手续费不超过分配金额;禁止把执行日 open/high/low/close 解释为下单前已知数据,也禁止用 T+1 prev_close 或 T 日估算股数直接成交next_bar_open 的涨停买入和跌停卖出判断必须比较实际 open 成交价与涨跌停价,不能用执行日 close/last 或 next-close。金额预算始终严格,execution.strict_value_budget(false) 会被拒绝。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。日线调仓现金口径由 execution.rebalance_cash_mode(\"sell_then_buy\" | \"same_point_net\" | \"pre_open_cash\") 或页面/API 参数控制,默认 sell_then_buysell_then_buy_delay_slippage_rate 只来自页面/API 执行参数,默认 0,不要写进策略表达式。滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 平台内核 的最坏成交价。".to_string(),
},
ManualSection {
title: "期货提交校验".to_string(),
@@ -280,6 +280,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
fields: vec![
ManualField { name: "signal_open/signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数当日开盘价与前一日收盘价。".to_string() },
ManualField { name: "benchmark_open/benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准当日开盘价与前一日收盘价。".to_string() },
ManualField { name: "benchmark_signal_close".to_string(), field_type: "float".to_string(), detail: "信号日收盘后可见的基准当日收盘价;用于 T 日生成信号、T+1 开盘成交的条件,不能在盘中或 T 日开盘决策中使用。".to_string() },
ManualField { name: "signal_ma5/signal_ma10/signal_ma20/signal_ma30".to_string(), field_type: "float".to_string(), detail: "信号指数滚动均线。".to_string() },
ManualField { name: "benchmark_ma5/benchmark_ma10/benchmark_ma20/benchmark_ma30".to_string(), field_type: "float".to_string(), detail: "基准指数滚动均线。".to_string() },
ManualField { name: "cash/available_cash/frozen_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户可用资金、挂单冻结资金、市值与总权益;available_cash 会扣减当前买入挂单冻结估算。".to_string() },
@@ -365,7 +366,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualFunction { name: "get_dominant_future / dominant_future / dominant_future_price".to_string(), signature: "dominant_future(\"IF\") / dominant_future_price(\"IF\", \"close\", lookback=1)".to_string(), detail: "主力合约 API。dominant_future 返回当前日期匹配前缀的主力期货合约代码;dominant_future_price 读取该主力合约最近 N 个交易日指定字段的最新价格。Rust Context 可用 ctx.get_dominant_future(...) 和 ctx.get_dominant_future_price(...)。".to_string() },
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力".to_string() },
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。回测中 deposit_withdraw 正数入金、负数出金receiving_days 大于 0 时按交易日延迟到账,并保持现金流中性净值不把外部资金流当成收益;回测 finance_repay 与 management_fee 按账户合同结算。模拟盘只接受由 runtime 明确返回的即时 deposit_withdraw,并通过幂等现金流账本落库;延迟流、融资/管理费动作必须显式失败。实盘禁止策略侧改变现金,必须以券商资产和已核验资金流水为真相,策略返回上述动作会在下单前 fail-closed".to_string() },
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() },
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
@@ -471,7 +472,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
out.push_str("- 只输出完整 `engine-script` 代码;第一行必须是 `strategy(\"...\")`、`let`、`fn`、`const` 或 `//`。\n");
out.push_str("- 禁止输出 Markdown、解释、推理过程、JSON 包装、手册复述或结果报告。\n");
out.push_str("- 只使用支持语句块:`market`、`benchmark`、`signal`、`rebalance.every_days(...).at([...])`、`selection.limit`、`selection.market_cap_band`、`filter.stock_ma`、`filter.stock_expr`、`ordering.rank_by`、`ordering.rank_expr`、`allocation.buy_scale`、`risk.stop_loss`、`risk.take_profit`、`risk.index_exposure`、`risk.policy`、`risk.blacklist`、`execution.matching_type`、`execution.rebalance_cash_mode`、`execution.slippage`、`universe.exclude`。\n");
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。\n");
out.push_str("- `universe.exclude` 只用于用户明确要求的业务排除项;框架默认的 ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量、手续费和印花税等基础风控必须写 `risk.policy(...)` 或由运行态 RiskLimits 注入。源策略明确写出的业务选股排除必须保留为策略表达式,不能改写冻结的 selection 风控开关。\n");
out.push_str("- 禁止伪 DSL`filter(...)`、`rank(...)`、`select.top(...)`、`weight.equal(...)`、`sell_rule(...)`、`backtest(...)`、`risk.max_position(...)`。\n");
out.push_str("- 市值表达式字段只能用 `market_cap` 或 `free_float_cap`;不要使用数据库原始字段 `float_market_cap`。\n");
out.push_str("- 任意窗口价格均线使用 `rolling_mean(\"close\", n)` 或 `ma(\"close\", n)`;任意窗口均量使用 `rolling_mean(\"volume\", n)` 或 `vma(n)`;不要使用未列出的 `ma60`、`stock_ma60`、`signal_ma60` 或 `benchmark_ma60` 变量。\n");
@@ -481,10 +482,11 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
out.push_str(
"- `risk.index_exposure(...)` 只能传一个表达式;不要生成 `risk.exposure(...)`。\n",
);
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里\n");
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或策略明确声明的业务过滤条件;转换器不得自行把框架默认基础风控注入过滤表达式。源策略明确写出的 `!is_st`、`!is_star_st`、`!is_kcb`、`!is_bjse` 等业务选股排除必须原样保留,同时冻结的 `reject_*_selection` 值不得改变\n");
out.push_str("- 完整三元表达式 `cond ? a : b` 可在表达式参数中使用;若当前运行环境报 `Unknown operator: '?'`,先重编译并重启回测服务,不要改写策略语义掩盖运行时漂移。\n");
out.push_str("- `next_bar_open` 的选股、排序和仓位信号来自决策日,订单在下一可交易开盘撮合;不要使用执行日价格作为下单前信号。\n");
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
out.push_str("- 日线目标金额、目标比例和目标权重在 `next_bar_open` 下冻结 T 日收盘目标,T+1 按实际 open、滑点、卖后买延迟滑点、手续费和证券数量步长重算股数;禁止用 T+1 prev_close、T 日估算股数或 T+1 开盘后权益替代。金额预算始终严格,不能生成 `execution.strict_value_budget(false)`。\n");
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
out.push_str("## 语句块\n");
for item in &manual.statement_blocks {
@@ -560,15 +562,15 @@ pub fn build_generation_prompt(
prompt.push_str("- 生成的代码必须能转换为 strategy_spec 并提交 POST /v1/backtests。\n");
prompt.push_str("- 用户指定“持仓N只、目标持仓N、stocknum=N、selection.limit(N)”时,必须把最终持仓槽位写成 N;用户指定“至少/不少于N只”时,最终持仓槽位必须 >= N。\n");
prompt.push_str("- ");
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
prompt.push_str(PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT);
prompt.push('\n');
prompt.push_str("- 不要使用手册未列出的字段、函数或外部平台 API 名称。\n\n");
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n)rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last;不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_typenext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)\n"));
prompt.push_str("只允许使用这些可编译语句:market、benchmark、signal、rebalance.every_days(...).at([...])、selection.limit、selection.market_cap_band、filter.stock_ma、filter.stock_expr、ordering.rank_by、ordering.rank_expr、allocation.buy_scale、risk.stop_loss、risk.take_profit、risk.index_exposure、risk.policy、risk.blacklist、execution.matching_type、execution.rebalance_cash_mode、execution.slippage、universe.exclude。universe.exclude 只用于用户明确要求的业务排除项,不能表达 FIDC 基础风控。禁止输出 filter(...)、rank(...)、select.top(...)、weight.equal()、sell_rule(...)、backtest(...)、risk.max_position(...) 这类未支持伪语法。\n");
prompt.push_str(&format!("参数形态必须严格:selection.market_cap_band 必须写 field=\"market_cap\" 或 field=\"free_float_cap\", lower=..., upper=...;禁止使用 float_market_cap;禁止使用 ma60、stock_ma60、signal_ma60、benchmark_ma6060日价格均线写 rolling_mean(\"close\", 60) 或 ma(\"close\", 60),任意窗口均量写 rolling_mean(\"volume\", n) 或 vma(n)rolling_mean、rolling_sum/min/max/stddev/zscore、pct_change、factor_value 等 helper 的第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;不要生成 fn score() 这类零参数函数,股票字段排序直接写在 ordering.rank_expr 内或用带参数函数;布尔字段按布尔使用,不要写 is_st == 0filter.stock_expr 只写 alpha 或业务过滤条件,不要把 !is_st、!paused、!at_upper_limit、!at_lower_limit 这类基础风控散落在表达式里;risk.index_exposure 只能传一个数值表达式,不要使用 risk.exposurerisk.policy 只写 FIDC 基础风控、成交量和交易成本命名参数,必须覆盖完整默认配置面,例如 {DEFAULT_RISK_POLICY_DSL_PROMPT},不要用它表达策略择时或收益规则;完整三元表达式 cond ? a : b 可以使用,但不得输出残缺问号/冒号片段;日线回测 execution.matching_type 只能取 current_bar_close 或 next_bar_open,分钟线回测只能取 minute_last日线 execution.rebalance_cash_mode 只允许 same_point_net、sell_then_buy、pre_open_cash,分钟线固定使用 sell_then_buy不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_typenext_bar_open 只能使用决策日信号,不能把执行日价格当作下单前信息;next_bar_open 下 T 日只生成订单意图并在收盘冻结目标金额或目标权益,T+1 按实际 open、滑点、手续费和证券数量步长重算股数,不能用 T+1 prev_close 或 T 日估算股数直接成交;涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close;禁止用 T 日执行状态拦截 T+1 可交易订单;金额预算始终严格,禁止 execution.strict_value_budget(false)execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 请求滑点率,例如 0.002),并且请求里指定固定滑点时必须使用请求值\n"));
prompt.push_str("回测成功但 tradeCount=0 或 holdingCount=0 是无效策略;第一版必须保持稳定买入覆盖率,复杂因子只能在后续优化中逐步加严。\n");
prompt.push_str("可参考但不要照抄的最小模板,回复时不要包含 ``` 代码围栏:\nstrategy(\"cn_a_smallcap_factor_rotation\") {\nmarket(\"CN_A\")\nbenchmark(\"000852.SH\")\nsignal(\"000001.SH\")\nrebalance.every_days(5).at([\"10:18\"])\nselection.limit(40)\nselection.market_cap_band(field=\"market_cap\", lower=0, upper=1000)\nfilter.stock_expr(listed_days >= 60 && close > 2)\nordering.rank_by(\"market_cap\", \"asc\")\nallocation.buy_scale(1.0)\nrisk.policy(");
prompt.push_str(DEFAULT_RISK_POLICY_DSL_CODE);
prompt.push_str(")\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\n");
prompt.push_str(")\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.002)\n}\n\n");
prompt.push_str("用户目标:\n");
prompt.push_str(&format!("- {}\n", request.user_goal));
if !request.constraints.is_empty() {
@@ -596,7 +598,7 @@ pub fn build_optimization_prompt(
prompt.push_str("长度硬约束:策略代码目标 80 行以内,只保留必要 let/fn/strategy 块;不要复制下面的手册片段、历史策略全文或字段清单。\n");
prompt.push_str("优化不限制在原策略已有参数或少量扰动。只要 OmniQuant/FIDC 已支持,可以自由增加、修改、删除策略代码、参数、候选池、过滤函数、排序、仓位、止盈止损、调仓周期、指标因子和辅助函数;不得引入手册未列出的字段或外部平台 API 名称。\n");
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n");
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT);
prompt.push_str(PERFORMANCE_ACCEPTANCE_CONTRACT_PROMPT);
prompt.push('\n');
prompt.push_str("可以使用 Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计的日频 source rows 字段、已发布指标/因子 artifact 和表达式函数,例如 rolling_mean/ma/vma/rolling_sum/rolling_stddev/pct_change/factor/factor_value/factors;这些滚动/因子 helper 的字段参数只能是字段名或字符串字段名,不要嵌套表达式;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\n");
prompt.push_str("优化目标:\n");
@@ -627,7 +629,7 @@ mod tests {
use serde_json::json;
#[test]
fn generation_prompt_contains_three_year_return_acceptance_target() {
fn generation_prompt_uses_explicit_performance_acceptance_contract() {
let prompt = build_generation_prompt(
"manual",
&StrategyAiGenerateRequest {
@@ -640,14 +642,18 @@ mod tests {
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
assert!(prompt.contains("不得把已达标策略判为失败"));
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
assert!(!prompt.contains("总收益严格 > 120%"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
assert!(prompt.contains("不要回退 ficlaw-data"));
assert!(prompt.contains("ClickHouse"));
assert!(prompt.contains("T 日只生成订单意图"));
assert!(prompt.contains("按实际成交日判断"));
assert!(prompt.contains("禁止用 T 日执行状态拦截 T+1 可交易订单"));
assert!(prompt.contains("execution.rebalance_cash_mode"));
assert!(prompt.contains("same_point_net、sell_then_buy、pre_open_cash"));
assert!(prompt.contains("分钟线固定使用 sell_then_buy"));
assert!(prompt.contains("必须覆盖完整默认配置面"));
assert!(prompt.contains("reject_inactive_buy=true"));
assert!(prompt.contains("reject_inactive_sell=true"));
@@ -661,7 +667,15 @@ mod tests {
}
#[test]
fn optimization_prompt_contains_three_year_return_acceptance_target() {
fn manual_separates_explicit_business_selection_from_framework_risk_policy() {
let markdown = render_manual_markdown(&built_in_strategy_manual());
assert!(markdown.contains("源策略明确写出的业务选股排除属于策略本身"));
assert!(markdown.contains("不能反向修改冻结的 reject_*_selection 开关"));
assert!(markdown.contains("冻结的 `reject_*_selection` 值不得改变"));
}
#[test]
fn optimization_prompt_uses_explicit_performance_acceptance_contract() {
let prompt = build_optimization_prompt(
"manual",
&StrategyAiOptimizeRequest {
@@ -673,8 +687,9 @@ mod tests {
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 即视为满足收益目标"));
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
assert!(prompt.contains("只能来自用户目标、请求约束或不可变 candidate/promotion contract"));
assert!(prompt.contains("不得注入 120% 或其他默认数值"));
assert!(!prompt.contains("总收益严格 > 120%"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
assert!(prompt.contains("不要回退 ficlaw-data"));
assert!(prompt.contains("ClickHouse"));
+11 -4
View File
@@ -63,13 +63,20 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() {
assert_eq!(buy.stamp_tax, 0.0);
let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0);
assert!((sell.commission - 80.0).abs() < 1e-9);
assert!((sell.commission - 30.0).abs() < 1e-9);
assert!((sell.stamp_tax - 100.0).abs() < 1e-9);
}
#[test]
fn aiquant_cost_model_matches_alv_run_options() {
let model = ChinaAShareCostModel::aiquant_default();
fn configured_cost_model_matches_declared_run_options() {
let model =
ChinaAShareCostModel::from_trading_constraints(fidc_core::TradingConstraintConfig {
commission_rate: 0.0003,
minimum_commission: 5.0,
stamp_tax_rate_before_change: 0.0005,
stamp_tax_rate_after_change: 0.0005,
..fidc_core::TradingConstraintConfig::default()
});
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
assert!((buy.commission - 14.993652).abs() < 1e-9);
@@ -130,7 +137,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() {
assert!((first.commission - 5.0).abs() < 1e-9);
assert!(second.commission.abs() < 1e-9);
assert!((third.commission - 12.6).abs() < 1e-9);
assert!((third.commission - 1.6).abs() < 1e-9);
assert!((another_order.commission - 5.0).abs() < 1e-9);
}
+5 -1
View File
@@ -368,7 +368,11 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
first_date: buy_date,
},
BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaAShareCostModel {
commission_rate: 0.0008,
minimum_commission: 0.0,
..ChinaAShareCostModel::default()
},
ChinaEquityRuleHooks::default(),
PriceField::Open,
),
+20 -11
View File
@@ -42,7 +42,7 @@ impl Strategy for BuyThenHoldStrategy {
}
#[test]
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
let date1 = d(2025, 1, 2);
let delist_date = d(2025, 1, 3);
let date2 = d(2025, 1, 6);
@@ -306,24 +306,33 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
);
let result = engine.run().expect("backtest succeeds");
assert_eq!(result.fills.len(), 2);
assert_eq!(result.fills.len(), 1);
assert!(
result
.fills
.iter()
.any(|fill| fill.reason.contains("delisted_cash_settlement")
&& fill.symbol == "000001.SZ")
);
assert!(
result
.holdings_summary
.iter()
.all(|holding| holding.symbol != "000001.SZ")
.all(|fill| !fill.reason.contains("delisted_cash_settlement"))
);
let unresolved = result
.holdings_summary
.iter()
.find(|holding| holding.symbol == "000001.SZ")
.expect("unresolved delisted holding remains auditable");
assert_eq!(unresolved.quantity, 900);
assert_eq!(unresolved.last_price, 0.0);
assert_eq!(unresolved.market_value, 0.0);
assert!(result.equity_curve.iter().any(|point| {
point
.notes
.contains("unresolved_delisted_position symbol=000001.SZ")
&& point.notes.contains("settlement_action=missing")
&& point.notes.contains("valuation_policy=zero")
&& point.notes.contains("no_order=true")
}));
}
#[test]
fn engine_applies_successor_conversion_before_delisted_cash_settlement() {
fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let data = DataSet::from_components_with_actions(
+7
View File
@@ -2648,6 +2648,13 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
assert!((result.equity_curve[0].total_equity - 10_458.0).abs() < 1e-6);
assert!((result.equity_curve[1].cash - 12_416.0).abs() < 1e-6);
assert!((result.equity_curve[1].total_equity - 11_416.0).abs() < 1e-6);
assert!((result.equity_curve[0].external_cash_flow - 500.0).abs() < 1e-6);
assert!((result.equity_curve[1].external_cash_flow - 1_000.0).abs() < 1e-6);
assert!((result.metrics.external_cash_flow_total - 1_500.0).abs() < 1e-6);
// The 1,500 external cash contribution must not be reported as a
// strategy return. Only the explicit management fee affects NAV here.
assert!(result.metrics.total_return < 0.0);
assert!(result.metrics.total_return > -0.01);
assert!(result.account_events.iter().any(|event| {
event
.note
+178 -12
View File
@@ -3321,7 +3321,8 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
.order_events
.iter()
.all(|event| !(event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)),
"optimizer should skip unfunded rebalance buy when locked holding cannot be sold"
"optimizer should skip unfunded rebalance buy when locked holding cannot be sold: {:#?}",
report
);
assert_eq!(
portfolio
@@ -3349,7 +3350,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
}
#[test]
fn rebalance_uses_prev_close_for_open_auction_valuation() {
fn rebalance_uses_day_open_for_open_auction_valuation() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
@@ -3515,7 +3516,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
let held = portfolio.position("000001.SZ").expect("held position");
let target = portfolio.position("000002.SZ").expect("target position");
assert_eq!(held.quantity, 500);
assert_eq!(target.quantity, 400);
assert_eq!(target.quantity, 900);
assert_eq!(report.fill_events.len(), 2);
assert!(
report
@@ -3531,7 +3532,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
.iter()
.any(|fill| fill.symbol == "000002.SZ"
&& fill.side == fidc_core::OrderSide::Buy
&& fill.quantity == 400)
&& fill.quantity == 900)
);
}
@@ -3714,14 +3715,179 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
.iter()
.any(|event| event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)
);
assert!(
report
.diagnostics
.iter()
.any(|line| line.contains("rebalance_safety_scaled")
|| line.contains("rebalance_buy_reduced")),
"expected rebalance diagnostics when cash is tight, got {:?}",
report.diagnostics
assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
}
#[test]
fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![
Instrument {
symbol: "000001.SZ".to_string(),
name: "TargetA".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
},
Instrument {
symbol: "000002.SZ".to_string(),
name: "TargetB".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
},
],
vec![
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 82.0,
open: 82.0,
high: 83.0,
low: 81.0,
close: 82.0,
last_price: 82.0,
bid1: 81.99,
ask1: 82.01,
prev_close: 82.0,
volume: 100_000,
minute_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 90.2,
lower_limit: 73.8,
price_tick: 0.01,
},
DailyMarketSnapshot {
date,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 82.0,
open: 82.0,
high: 83.0,
low: 81.0,
close: 82.0,
last_price: 82.0,
bid1: 81.99,
ask1: 82.01,
prev_close: 82.0,
volume: 100_000,
minute_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 90.2,
lower_limit: 73.8,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 60.0,
free_float_cap_bn: 50.0,
pe_ttm: 18.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
},
],
vec![
CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
CandidateEligibility {
date,
symbol: "000002.SZ".to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
},
],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(100_000.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([
("000001.SZ".to_string(), 0.48),
("000002.SZ".to_string(), 0.48),
]),
exit_symbols: BTreeSet::new(),
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(
portfolio
.position("000001.SZ")
.map(|position| position.quantity),
Some(500)
);
assert_eq!(
portfolio
.position("000002.SZ")
.map(|position| position.quantity),
Some(500)
);
}
+3 -1
View File
@@ -20,7 +20,9 @@ futures path. Confirmed aligned areas:
pending limit orders, cancellation, open order views, and final order lookup.
- Stock account and portfolio runtime fields including cash, frozen cash, total
value, transaction cost, trading/position PnL, management fees, financing
liability, deposit/withdraw, and position aliases.
liability, deposit/withdraw, and position aliases. External deposits and
withdrawals are unitized separately from trading PnL; delayed withdrawals
are preflighted atomically at settlement.
- Scheduler, dynamic universe, subscription guard, `history_bars`,
`current_snapshot`, `get_price`, instruments, trading-date APIs, suspension
and ST helpers.
+4 -1
View File
@@ -49,7 +49,10 @@ PY
run_core_test eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled
run_core_test next_bar_open_eligible_universe_helper_does_not_block_on_decision_day_risk
run_core_test platform_next_open_selection_records_risk_diagnostics_without_filtering
run_core_test platform_selection_uses_complete_static_risk_policy_before_universe_output
run_core_test platform_next_open_defers_complete_static_selection_risk_before_universe_output
run_core_test platform_next_open_selection_ignores_execution_day_limit_state
run_core_test platform_next_open_selection_applies_explicit_signal_day_risk_filter
run_core_test next_open_buy_risk_uses_execution_date_not_signal_date
run_core_test next_open_buy_limit_risk_uses_open_not_close
run_core_test next_open_sell_risk_uses_execution_date_not_signal_date