Compare commits

..

147 Commits

Author SHA1 Message Date
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
boris 203e17ce87 修正指数择时使用信号指数 2026-07-08 03:27:06 +08:00
boris 32a417d6d1 Revert "支持强市目标仓位微调"
This reverts commit 2de7127f02.
2026-07-08 03:22:13 +08:00
boris 2de7127f02 支持强市目标仓位微调 2026-07-08 03:18:45 +08:00
boris a5a9688599 回退弱市调仓执行层分拆 2026-07-08 03:12:38 +08:00
boris ad49fc89d3 修正弱市调仓与止盈分拆顺序 2026-07-08 03:10:34 +08:00
boris 6329a8a0da 修正延迟卖出后续补仓控制 2026-07-08 03:08:24 +08:00
boris 69b793cbb8 修正延迟涨停卖出补仓槽位 2026-07-08 03:03:41 +08:00
boris f8f01a0987 修正历史清仓残量补仓语义 2026-07-08 02:58:24 +08:00
boris 4ce52a7af6 修正部分卖出持仓成本 2026-07-08 02:44:31 +08:00
boris ca0471799b 修正ALV残余清仓重判逻辑 2026-07-08 02:38:19 +08:00
boris 0febd3d644 修正ALV清仓禁买跨日状态 2026-07-08 02:31:23 +08:00
boris c085730ca5 修正挂起清仓补仓占位 2026-07-08 02:24:04 +08:00
boris 538edb907d 修正ALV连续补仓槽位释放 2026-07-08 02:22:17 +08:00
boris a854a4ec02 合并延迟清仓与补仓占位 2026-07-08 02:13:45 +08:00
boris ec098c6d39 限制同轮补仓重复释放槽位 2026-07-08 02:12:09 +08:00
boris 17bac07a86 补齐延迟清仓补仓占位 2026-07-08 02:05:29 +08:00
boris 63da6bb1bd 修正ALV补仓槽位占用语义 2026-07-08 02:03:15 +08:00
boris e847ecd54c 修正未完成清仓预算槽释放 2026-07-08 01:55:17 +08:00
boris 51ee4a3f54 按持仓顺序处理未完成清仓补仓 2026-07-08 01:53:00 +08:00
boris 8c4156948e 修复ALV日内补仓循环次数 2026-07-08 01:48:15 +08:00
boris 89f8bb32d0 对齐ALV日内补仓执行顺序 2026-07-08 01:44:32 +08:00
boris e0a7eb8972 修正FIDC未完成清仓占位预算 2026-07-08 01:12:30 +08:00
boris 12da5a4704 对齐ALV补仓订单返回语义 2026-07-08 00:59:11 +08:00
boris 749b5e3b9c 修正FIDC止损未成交后补仓语义 2026-07-08 00:43:25 +08:00
boris e74e2226d5 修正AiQuant兼容选股剔除北交所 2026-07-07 23:57:14 +08:00
boris 5a1534e51e 修复预计算rolling缺失语义 2026-07-07 23:05:26 +08:00
boris 22451300b1 修复AiQuant同批目标调仓净额语义 2026-07-07 22:44:49 +08:00
boris a50e59ab1d 修复AiQuant止损调仓顺序语义 2026-07-07 22:11:54 +08:00
boris 6d86eab021 修正缺字段误触发强制退出 2026-07-07 16:15:43 +08:00
boris 6b306eecf2 修正FIDC风控字段与默认选股语义 2026-07-07 13:14:34 +08:00
boris 90857fae0a 修复目标组合风控拒单记录 2026-07-07 09:09:29 +08:00
boris b37ebb81f1 修复策略显式排除股票匹配 2026-07-07 07:19:10 +08:00
boris e37b8e1265 限制延迟滑点现金口径 2026-07-07 06:36:33 +08:00
boris 3ef4029c4a 修正FIDC执行日风控配置 2026-07-06 20:05:11 +08:00
boris cb189e3de4 完善FIDC策略执行语义 2026-07-06 14:52:50 +08:00
boris 4fee8e1d07 修正精确分钟执行价回退逻辑 2026-07-06 10:55:01 +08:00
boris 64298f09c1 修正涨停持仓延迟卖出打标 2026-07-06 10:27:18 +08:00
boris 60bfa28ef0 修正季节清仓执行时间 2026-07-06 10:23:23 +08:00
boris 8c5a2ef611 修正涨停卖出挂起语义 2026-07-06 10:16:39 +08:00
boris db1ffb5918 修复AiQuant转换调度和不可卖调仓语义 2026-07-06 10:07:56 +08:00
boris fe39a75e6e 修复分钟执行价缺失档位量成交 2026-07-06 09:54:14 +08:00
boris 1d33b29c27 修复季节性清仓执行时间 2026-07-06 09:40:07 +08:00
boris 831edfc8c6 修复平台策略部分清仓跨日续卖 2026-07-06 08:52:53 +08:00
boris c3ab279d7d 修复分钟执行价成交量预过滤 2026-07-06 08:44:57 +08:00
boris 6820b63d56 修复平台策略每日调仓和止损下单 2026-07-06 08:26:16 +08:00
boris fdc099c960 修正内置选择器next-open风控语义 2026-07-06 08:22:59 +08:00
boris afb531da59 修正显式订单测试成交量口径 2026-07-06 08:19:21 +08:00
boris 7c867f1788 修复分钟成交量限制撮合 2026-07-06 08:05:59 +08:00
boris a3415095a7 补充分钟买入投影调试信息 2026-07-06 07:26:20 +08:00
boris 3e3bebf3e0 修复分钟成交量限制误用盘口量 2026-07-06 07:22:58 +08:00
boris 99a21324db 修复回测持仓天数和选股风控语义 2026-07-06 05:54:47 +08:00
boris 92d5801f63 修正平台表达式选股风控补位 2026-07-06 05:45:52 +08:00
boris 551421818b 补齐FIDC风控安全开关合同 2026-07-06 01:11:39 +08:00
boris de1373deae 修复日线投影缺盘口量拒单 2026-07-05 23:06:51 +08:00
boris f9f9706900 增加平台买入投影诊断开关 2026-07-05 23:03:48 +08:00
boris 74105f0fde 修复日线投影成交量限制基数 2026-07-05 22:59:28 +08:00
boris 72c667d790 修复next-open信号日投影缺quote问题 2026-07-05 22:53:03 +08:00
boris 82ed5b3e0b 新增FIDC运行时风控合同验证 2026-07-05 19:35:39 +08:00
boris b489abba6b 修正next open选股风控测试命名 2026-07-05 19:12:47 +08:00
boris 203a20592a 修正平台表达式选股风控缺口 2026-07-05 18:19:14 +08:00
boris 7059d3a8d0 补充next-open选股风控延后回归测试 2026-07-05 17:44:34 +08:00
boris 289448d196 修复FIDC默认选股池依赖风控事实 2026-07-05 17:13:51 +08:00
boris c557656040 修复策略投影成交量约束 2026-07-05 17:01:04 +08:00
boris 7189998699 修正FIDC选股风控延后边界 2026-07-05 15:05:00 +08:00
boris 00c9042c15 修复FIDC选股阶段风控语义 2026-07-05 14:51:54 +08:00
boris d3c986e1f2 修复next-open执行日风控价格 2026-07-05 13:48:01 +08:00
boris 2f61bd8e57 修复next-open涨跌停风控价格口径 2026-07-05 10:41:27 +08:00
boris 61ad4119cf 修复末日next open执行候选测试 2026-07-05 09:40:32 +08:00
boris ab31006d01 修正末日执行测试诊断位置 2026-07-05 09:39:21 +08:00
boris 584a38c7a7 修正next open诊断断言位置 2026-07-05 09:38:34 +08:00
boris 1d817c7f50 增加next open末日执行诊断 2026-07-05 09:37:41 +08:00
boris b327bb074e 使用on day调度验证末日执行 2026-07-05 09:37:07 +08:00
boris 8ed2b0df7f 补充指定决策日调度测试策略 2026-07-05 09:36:27 +08:00
boris a21ac83f21 调整next open末日执行测试入口 2026-07-05 09:35:46 +08:00
boris 13e15cc7c4 补充next open开盘执行回归测试 2026-07-05 09:35:00 +08:00
boris ad6e168303 修正next open末日执行测试 2026-07-05 09:33:34 +08:00
boris 73627b1b2d 修正next open执行日回归用例 2026-07-05 09:33:15 +08:00
boris 1219b42046 修复next open决策执行日映射 2026-07-05 09:31:56 +08:00
boris ba2470aefe 补齐回测交易日期审计字段 2026-07-05 08:47:52 +08:00
boris 8543c3ab6d 收紧回测引擎旧数据源扫描范围 2026-07-05 07:04:56 +08:00
boris 9a16ceefbb 收紧回测引擎旧缓存门禁 2026-07-05 06:01:36 +08:00
boris 549595c1c6 修正FIDC选股阶段风控语义 2026-07-05 03:08:38 +08:00
boris 8125ea2e3b 补全Platform安全除法整数重载 2026-07-05 03:00:34 +08:00
boris 3e4270729b 修复Platform选股阶段风控语义 2026-07-05 02:49:53 +08:00
boris 9aa156eb2a 完善策略生成滚动函数参数约束 2026-07-05 02:15:57 +08:00
boris 4e3ae3b378 修复平台表达式安全除法运行时 2026-07-05 02:01:55 +08:00
boris 339f85c27b 补齐next-open卖出执行日风控测试 2026-07-04 21:45:23 +08:00
boris aff7fa309c 补齐策略生成风控提示词 2026-07-04 21:23:49 +08:00
boris cb02041b3b 收紧风控策略别名冲突校验 2026-07-04 20:35:06 +08:00
boris e8ecc037c9 补充next open执行日风控测试 2026-07-04 20:04:30 +08:00
boris 652531ac63 补齐策略AI持仓合同请求类型 2026-07-04 19:15:13 +08:00
boris a8ffd36150 保留AiQuant盘中卖出价格风控语义 2026-07-04 17:02:55 +08:00
boris 143a021067 统一FIDC卖出allow_sell风控 2026-07-04 17:01:02 +08:00
boris 487e1a38aa 修正退市风控测试数据 2026-07-04 16:52:58 +08:00
boris e70d637ade 修正FIDC退市风控原因识别 2026-07-04 16:50:40 +08:00
17 changed files with 11185 additions and 913 deletions
File diff suppressed because it is too large Load Diff
+140 -106
View File
@@ -448,49 +448,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)]
@@ -2493,14 +2456,7 @@ impl DataSet {
pub fn eligible_universe_on(&self, date: NaiveDate) -> &[EligibleUniverseSnapshot] {
self.eligible_universe_by_date
.get_or_init(|| {
build_eligible_universe(
&self.factor_by_date,
&self.candidate_by_date,
&self.market_by_date,
&self.instruments,
)
})
.get_or_init(|| build_eligible_universe(&self.factor_by_date, &self.market_by_date))
.get(&date)
.map(Vec::as_slice)
.unwrap_or(&[])
@@ -3033,22 +2989,12 @@ fn build_order_book_depth_index(
fn build_eligible_universe(
factor_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyFactorSnapshot>>>,
candidate_by_date: &BTreeMap<NaiveDate, Vec<Arc<CandidateEligibility>>>,
market_by_date: &BTreeMap<NaiveDate, Vec<Arc<DailyMarketSnapshot>>>,
instruments: &HashMap<String, Instrument>,
) -> BTreeMap<NaiveDate, Vec<EligibleUniverseSnapshot>> {
let mut per_date = BTreeMap::<NaiveDate, Vec<EligibleUniverseSnapshot>>::new();
let risk_config = FidcRiskControlConfig::default();
for (date, factors) in factor_by_date {
let rows = build_eligible_universe_for_date_from_factors(
*date,
factors,
candidate_by_date,
market_by_date,
instruments,
&risk_config,
);
for date in factor_by_date.keys() {
let rows = build_fundamental_universe_for_date(*date, factor_by_date, market_by_date);
per_date.insert(*date, rows);
}
@@ -3065,20 +3011,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| {
@@ -3126,11 +3073,15 @@ fn build_eligible_universe_for_date_from_factors(
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
continue;
}
let Some(candidate) = candidate_by_date
let synthetic_candidate;
let candidate = if let Some(candidate) = candidate_by_date
.get(&date)
.and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str()))
else {
continue;
{
candidate
} else {
synthetic_candidate = missing_candidate_risk_state(date, &factor.symbol);
&synthetic_candidate
};
let Some(market) = market_by_date
.get(&date)
@@ -3149,11 +3100,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,
@@ -3169,6 +3120,25 @@ fn build_eligible_universe_for_date_from_factors(
rows
}
pub(crate) fn missing_candidate_risk_state(date: NaiveDate, symbol: &str) -> CandidateEligibility {
CandidateEligibility {
date,
symbol: symbol.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: Some(
"missing_risk_state:is_st,is_star_st,is_paused,listed_days,is_kcb,is_one_yuan"
.to_string(),
),
}
}
#[cfg(test)]
fn instrument_passes_baseline_selection(instrument: Option<&Instrument>, date: NaiveDate) -> bool {
ChinaAShareRiskControl::instrument_rejection_reason(instrument, date).is_none()
@@ -3438,11 +3408,95 @@ 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]
fn eligible_universe_does_not_require_candidate_risk_state_when_selection_risk_is_disabled() {
let date = NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap();
let symbol = "000001.SZ";
let data = DataSet::from_components(
vec![Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(NaiveDate::parse_from_str("2020-01-01", "%Y-%m-%d").unwrap()),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.2,
low: 9.8,
close: 10.1,
last_price: 10.1,
bid1: 10.0,
ask1: 10.1,
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,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 9.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}],
Vec::new(),
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 100.0,
close: 101.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
assert_eq!(
data.eligible_universe_on(date)
.iter()
.map(|row| row.symbol.as_str())
.collect::<Vec<_>>(),
vec![symbol]
);
assert_eq!(
data.eligible_universe_on_with_risk_config(date, &FidcRiskControlConfig::default())
.iter()
.map(|row| row.symbol.as_str())
.collect::<Vec<_>>(),
vec![symbol],
"execution-risk defaults must not make selection depend on candidate risk facts"
);
let mut selection_risk_config = FidcRiskControlConfig::default();
selection_risk_config.static_rules.reject_st_selection = true;
assert!(
data.eligible_universe_on_with_risk_config(date, &selection_risk_config)
.is_empty(),
"explicit selection risk must reject when required candidate facts are missing"
);
}
#[test]
@@ -3516,8 +3570,13 @@ mod tests {
)
.expect("dataset");
assert!(data.eligible_universe_on(date).is_empty());
assert_eq!(data.eligible_universe_on(date).len(), 1);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_kcb_selection = true;
assert!(
data.eligible_universe_on_with_risk_config(date, &risk_config)
.is_empty()
);
risk_config.static_rules.reject_kcb_selection = false;
let rows = data.eligible_universe_on_with_risk_config(date, &risk_config);
@@ -3526,33 +3585,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(),
@@ -3561,11 +3595,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]
+369 -24
View File
@@ -1192,6 +1192,9 @@ where
});
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: Some(order_id),
symbol: self
.futures_open_orders
@@ -1229,6 +1232,9 @@ where
let mut report = FuturesExecutionReport::default();
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: Some(order_id),
symbol: intent.symbol.clone(),
side,
@@ -1646,7 +1652,7 @@ where
let mut portfolio = PortfolioState::new(self.config.initial_cash);
let scheduler_calendar = self.data.calendar().clone();
let scheduler = Scheduler::new(&scheduler_calendar);
let execution_dates = self
let calendar_dates = self
.data
.calendar()
.iter()
@@ -1657,11 +1663,41 @@ where
.unwrap_or(true)
})
.filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true))
.filter(|date| {
!self.data.factor_snapshots_on(*date).is_empty()
&& !self.data.candidate_snapshots_on(*date).is_empty()
})
.collect::<Vec<_>>();
let has_decision_inputs = |date: NaiveDate| {
!self.data.factor_snapshots_on(date).is_empty()
&& !self.data.candidate_snapshots_on(date).is_empty()
};
let has_execution_market =
|date: NaiveDate| !self.data.market_snapshots_on(date).is_empty();
let mut execution_dates = Vec::new();
let mut decision_slots = Vec::new();
for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() {
if self.config.decision_lag_trading_days == 0 {
if has_decision_inputs(execution_date) {
execution_dates.push(execution_date);
decision_slots.push(Some((calendar_idx, execution_date)));
}
continue;
}
if !has_execution_market(execution_date) {
continue;
}
let decision_slot = calendar_idx
.checked_sub(self.config.decision_lag_trading_days)
.map(|decision_idx| (decision_idx, calendar_dates[decision_idx]));
match decision_slot {
Some((_, decision_date)) if has_decision_inputs(decision_date) => {
execution_dates.push(execution_date);
decision_slots.push(decision_slot);
}
None => {
execution_dates.push(execution_date);
decision_slots.push(None);
}
_ => {}
}
}
let mut result = BacktestResult {
strategy_name: self.strategy.name().to_string(),
benchmark_series: self
@@ -1704,31 +1740,54 @@ where
&mut portfolio,
&mut corporate_action_notes,
);
self.extend_result(&mut result, pending_cash_flow_report);
self.extend_result(
&mut result,
pending_cash_flow_report,
execution_date,
execution_date,
);
let corporate_action_report = self.apply_corporate_actions(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(&mut result, corporate_action_report);
self.extend_result(
&mut result,
corporate_action_report,
execution_date,
execution_date,
);
let receivable_report = self.settle_cash_receivables(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(&mut result, receivable_report);
self.extend_result(
&mut result,
receivable_report,
execution_date,
execution_date,
);
let delisting_report = self.settle_delisted_positions(
execution_date,
&mut portfolio,
&mut corporate_action_notes,
)?;
self.extend_result(&mut result, delisting_report);
self.extend_result(
&mut result,
delisting_report,
execution_date,
execution_date,
);
let futures_open_order_report = self.process_futures_open_orders(execution_date);
self.extend_result(&mut result, futures_open_order_report);
self.extend_result(
&mut result,
futures_open_order_report,
execution_date,
execution_date,
);
let decision_slot = execution_idx
.checked_sub(self.config.decision_lag_trading_days)
.map(|decision_idx| (decision_idx, execution_dates[decision_idx]));
let decision_slot = decision_slots.get(execution_idx).copied().flatten();
let Some((decision_index, decision_date)) = decision_slot else {
let mut process_events = Vec::new();
let mut report = BrokerExecutionReport::default();
@@ -1750,7 +1809,7 @@ where
let day_fills = report.fill_events.clone();
let broker_diagnostics = report.diagnostics.clone();
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
self.extend_result(&mut result, report);
self.extend_result(&mut result, report, execution_date, execution_date);
result.risk_decisions.extend(execution_risk_decisions);
let benchmark =
@@ -2014,8 +2073,10 @@ where
None,
None,
)?;
let mut report = self.broker.execute(
let mut report = self.broker.execute_with_event_dates(
execution_date,
decision_date,
decision_date,
&mut portfolio,
&self.data,
&auction_decision,
@@ -2260,9 +2321,14 @@ where
None,
None,
)?;
let mut intraday_report =
self.broker
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
let mut intraday_report = self.broker.execute_with_event_dates(
execution_date,
decision_date,
decision_date,
&mut portfolio,
&self.data,
&decision,
)?;
let post_intraday_open_orders = self.open_order_views();
publish_process_events(
&mut self.strategy,
@@ -2426,8 +2492,10 @@ where
Some(minute_time),
Some(minute_time),
)?;
let mut minute_report = self.broker.execute_between(
let mut minute_report = self.broker.execute_between_with_event_dates(
execution_date,
decision_date,
decision_date,
&mut portfolio,
&self.data,
&minute_decision,
@@ -2750,7 +2818,7 @@ where
let day_fills = report.fill_events.clone();
let broker_diagnostics = report.diagnostics.clone();
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
self.extend_result(&mut result, report);
self.extend_result(&mut result, report, decision_date, execution_date);
result.risk_decisions.extend(decision.risk_decisions);
result.risk_decisions.extend(execution_risk_decisions);
@@ -2838,8 +2906,11 @@ where
fn extend_result(
&self,
result: &mut BacktestResult,
report: BrokerExecutionReport,
mut report: BrokerExecutionReport,
decision_date: NaiveDate,
execution_date: NaiveDate,
) -> BrokerExecutionReport {
annotate_broker_report_dates(&mut report, decision_date, decision_date, execution_date);
result.order_events.extend(report.order_events.clone());
result.fills.extend(report.fill_events.clone());
result
@@ -3063,6 +3134,9 @@ where
);
report.fill_events.push(FillEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: None,
symbol: receivable.symbol.clone(),
side: OrderSide::Buy,
@@ -3366,6 +3440,9 @@ where
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,
@@ -3376,6 +3453,9 @@ where
});
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,
@@ -3827,6 +3907,24 @@ fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExe
target.diagnostics.extend(incoming.diagnostics);
}
fn annotate_broker_report_dates(
report: &mut BrokerExecutionReport,
decision_date: NaiveDate,
order_created_date: NaiveDate,
execution_date: NaiveDate,
) {
for event in &mut report.order_events {
event.decision_date.get_or_insert(decision_date);
event.order_created_date.get_or_insert(order_created_date);
event.execution_date.get_or_insert(execution_date);
}
for fill in &mut report.fill_events {
fill.decision_date.get_or_insert(decision_date);
fill.order_created_date.get_or_insert(order_created_date);
fill.execution_date.get_or_insert(execution_date);
}
}
fn risk_decisions_from_order_events(order_events: &[OrderEvent]) -> Vec<FidcRiskDecisionAudit> {
order_events
.iter()
@@ -4021,6 +4119,9 @@ fn futures_cancel_report(
});
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id: Some(order.order_id),
symbol: order.intent.symbol.clone(),
side,
@@ -4150,6 +4251,78 @@ mod tests {
}
}
#[derive(Debug)]
struct ScheduledBuyOnDecisionDateStrategy {
rule: ScheduleRule,
decision_date: NaiveDate,
}
impl Strategy for ScheduledBuyOnDecisionDateStrategy {
fn name(&self) -> &str {
"scheduled_buy_on_decision_date"
}
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::Shares {
symbol: SYMBOL.to_string(),
quantity: 100,
reason: "scheduled_decision_date_buy".to_string(),
}],
..StrategyDecision::default()
})
}
}
#[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 ScheduledEligibleUniverseBuyStrategy {
rule: ScheduleRule,
@@ -4779,9 +4952,103 @@ mod tests {
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, d(2025, 1, 3));
assert_eq!(result.fills[0].decision_date, Some(d(2025, 1, 2)));
assert_eq!(result.fills[0].order_created_date, Some(d(2025, 1, 2)));
assert_eq!(result.fills[0].execution_date, Some(d(2025, 1, 3)));
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_executes_last_decision_without_execution_day_factor_snapshot() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let dataset = DataSet::from_components(
vec![default_instrument()],
vec![
market(first, 10.0, 11.5),
market(second, 12.0, 13.0),
market(third, 14.0, 15.0),
],
vec![factor(first), factor(second)],
vec![candidate(first), candidate(second), candidate(third)],
vec![benchmark(first), benchmark(second), benchmark(third)],
)
.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(third),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
};
let result = BacktestEngine::new(
dataset,
ScheduledBuyOnDecisionDateStrategy {
rule: ScheduleRule::daily("daily_signal", ScheduleStage::OnDay),
decision_date: second,
},
broker,
config,
)
.run()
.expect("backtest run");
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, third);
assert_eq!(result.fills[0].decision_date, Some(second));
assert_eq!(result.fills[0].execution_date, Some(third));
assert_eq!(result.fills[0].price, 14.0);
}
#[test]
fn next_bar_open_strategy_context_data_helpers_use_decision_date() {
let first = d(2025, 1, 2);
@@ -4817,9 +5084,22 @@ mod tests {
.map(|snapshot| snapshot.close),
Some(11.5)
);
assert_eq!(
dataset
.eligible_universe_on(first)
.iter()
.map(|row| row.symbol.clone())
.collect::<Vec<_>>(),
vec![SYMBOL.to_string()],
"raw DataSet helper should not apply default selection risk"
);
let mut selection_risk_config = FidcRiskControlConfig::default();
selection_risk_config.static_rules.reject_paused_selection = true;
assert!(
dataset.eligible_universe_on(first).is_empty(),
"raw DataSet helper remains a risk-filtered universe"
dataset
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
.is_empty(),
"explicit raw DataSet selection risk can still filter the universe"
);
assert_eq!(
manual_ctx
@@ -4832,7 +5112,7 @@ mod tests {
);
assert_eq!(
manual_ctx
.eligible_universe_on_with_risk_config(first, &FidcRiskControlConfig::default())
.eligible_universe_on_with_risk_config(first, &selection_risk_config)
.into_iter()
.map(|row| row.symbol)
.collect::<Vec<_>>(),
@@ -5025,6 +5305,30 @@ mod tests {
);
}
#[test]
fn next_bar_open_execution_risk_uses_open_not_close_for_upper_limit_buy() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market_with_state(second, 11.8, 12.0, false, 12.0, 1.0),
candidate(first),
candidate(second),
));
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].price, 11.8);
assert!(
result
.order_events
.iter()
.all(|event| !event.reason.contains("upper limit")),
"{:?}",
result.order_events
);
}
#[test]
fn next_bar_open_execution_risk_rejects_execution_day_st_state() {
let first = d(2025, 1, 2);
@@ -5159,6 +5463,47 @@ mod tests {
assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit");
}
#[test]
fn next_bar_open_sell_risk_uses_open_not_close_for_lower_limit_sell() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let third = d(2025, 1, 6);
let fourth = d(2025, 1, 7);
let result = run_scheduled_round_trip_next_open_with_dataset_and_broker(
dataset_from_market_and_candidates(
vec![
market(first, 10.0, 10.5),
market(second, 11.0, 11.5),
market(third, 12.0, 12.5),
market_with_state(fourth, 9.2, 9.0, false, 20.0, 9.0),
],
vec![
candidate(first),
candidate(second),
candidate(third),
candidate(fourth),
],
),
scheduled_next_open_broker(FidcRiskControlConfig::default()),
);
let sell_fill = result
.fills
.iter()
.find(|fill| fill.side == OrderSide::Sell)
.expect("sell should execute when next-open is above lower limit");
assert_eq!(sell_fill.date, fourth);
assert_eq!(sell_fill.price, 9.2);
assert!(
result
.order_events
.iter()
.all(|event| !event.reason.contains("lower limit")),
"{:?}",
result.order_events
);
}
#[test]
fn next_bar_open_sell_respects_allow_sell_policy_on_execution_day() {
let first = d(2025, 1, 2);
+39
View File
@@ -23,6 +23,33 @@ mod date_format {
}
}
mod optional_date_format {
use chrono::NaiveDate;
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d";
pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match date {
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|text| NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom))
.transpose()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OrderSide {
Buy,
@@ -63,6 +90,12 @@ impl OrderStatus {
pub struct OrderEvent {
#[serde(with = "date_format")]
pub date: NaiveDate,
#[serde(default, with = "optional_date_format")]
pub decision_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub order_created_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub execution_date: Option<NaiveDate>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
@@ -77,6 +110,12 @@ pub struct OrderEvent {
pub struct FillEvent {
#[serde(with = "date_format")]
pub date: NaiveDate,
#[serde(default, with = "optional_date_format")]
pub decision_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub order_created_date: Option<NaiveDate>,
#[serde(default, with = "optional_date_format")]
pub execution_date: Option<NaiveDate>,
#[serde(default)]
pub order_id: Option<u64>,
pub symbol: String,
+12
View File
@@ -746,6 +746,9 @@ impl FuturesAccountState {
);
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
@@ -823,6 +826,9 @@ impl FuturesAccountState {
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
report.fill_events.push(FillEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol.clone(),
side,
@@ -889,6 +895,9 @@ impl FuturesAccountState {
});
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
@@ -915,6 +924,9 @@ impl FuturesAccountState {
);
report.order_events.push(OrderEvent {
date,
decision_date: None,
order_created_date: None,
execution_date: None,
order_id,
symbol: intent.symbol,
side,
+6 -4
View File
@@ -20,7 +20,8 @@ pub mod strategy_ai;
pub mod universe;
pub use broker::{
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, SlippageModel,
BrokerExecutionReport, BrokerSimulator, DynamicSlippageConfig, MatchingType, RebalanceCashMode,
SlippageModel,
};
pub use calendar::TradingCalendar;
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
@@ -84,9 +85,10 @@ pub use strategy::{
};
pub use strategy_ai::{
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiManual,
StrategyAiOptimizeRequest, build_generation_prompt, build_optimization_prompt,
built_in_strategy_manual, merge_catalog_into_manual, render_manual_markdown,
ManualSection, StrategyAiCatalog, StrategyAiGenerateRequest, StrategyAiHoldingCountContract,
StrategyAiManual, StrategyAiOptimizeRequest, build_generation_prompt,
build_optimization_prompt, built_in_strategy_manual, merge_catalog_into_manual,
render_manual_markdown,
};
pub use universe::{
BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
File diff suppressed because it is too large Load Diff
@@ -140,6 +140,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
"ask1_volume",
"turnover_ratio",
"effective_turnover_ratio",
"up_days_stock",
"open",
"high",
"low",
@@ -156,6 +157,7 @@ const RESERVED_SCOPE_NAMES: &[&str] = &[
"is_st",
"is_star_st",
"is_kcb",
"is_bjse",
"is_one_yuan",
"is_new_listing",
"allow_buy",
@@ -328,6 +330,7 @@ mod tests {
"avg_cost",
"current_price",
"stock_ma_short",
"up_days_stock",
] {
assert!(
names.contains(required),
+493 -25
View File
@@ -8,7 +8,7 @@ use crate::{
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction,
PlatformUniverseActionKind, ScheduleTimeRule, SlippageModel,
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -105,6 +105,10 @@ pub struct StrategyExecutionSpec {
pub risk_policy: Option<StrategyRiskPolicySpec>,
#[serde(default, alias = "strict_value_budget")]
pub strict_value_budget: Option<bool>,
#[serde(default, alias = "rebalance_cash_mode")]
pub rebalance_cash_mode: Option<String>,
#[serde(default, alias = "sell_then_buy_delay_slippage_rate")]
pub sell_then_buy_delay_slippage_rate: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -114,6 +118,8 @@ pub struct StrategyEngineConfig {
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")]
@@ -173,10 +179,22 @@ pub struct StrategyEngineConfig {
pub risk_policy: Option<StrategyRiskPolicySpec>,
#[serde(default, alias = "strict_value_budget")]
pub strict_value_budget: Option<bool>,
#[serde(default, alias = "rebalance_cash_mode")]
pub rebalance_cash_mode: Option<String>,
#[serde(default, alias = "sell_then_buy_delay_slippage_rate")]
pub sell_then_buy_delay_slippage_rate: Option<f64>,
#[serde(default)]
pub dividend_reinvestment: Option<bool>,
#[serde(default)]
pub weak_market_shrink_overweight_threshold: Option<f64>,
#[serde(
default,
alias = "max_hold_days",
alias = "max_holding_days",
alias = "maxHoldDays",
alias = "maxHoldingDays"
)]
pub max_holding_days: Option<i64>,
#[serde(default)]
pub rebalance_schedule: Option<StrategyExpressionScheduleConfig>,
#[serde(default)]
@@ -236,6 +254,10 @@ pub struct StrategyRiskPolicySpec {
pub forbid_same_day_rebuy_after_sell: Option<bool>,
#[serde(default, alias = "blacklist_enabled")]
pub blacklist_enabled: Option<bool>,
#[serde(default, alias = "allow_market_orders")]
pub allow_market_orders: Option<bool>,
#[serde(default, alias = "live_trading_enabled")]
pub live_trading_enabled: Option<bool>,
#[serde(
default,
alias = "blacklisted_symbols",
@@ -307,6 +329,8 @@ const RISK_POLICY_BOOL_ALIAS_GROUPS: &[(&str, &[&str])] = &[
&["forbid_same_day_rebuy_after_sell"],
),
("blacklistEnabled", &["blacklist_enabled"]),
("allowMarketOrders", &["allow_market_orders"]),
("liveTradingEnabled", &["live_trading_enabled"]),
("volumeLimitEnabled", &["volume_limit_enabled"]),
("liquidityLimitEnabled", &["liquidity_limit_enabled"]),
];
@@ -364,6 +388,55 @@ fn parse_policy_bool(canonical: &str, value: Value) -> Result<bool, String> {
}
}
fn policy_value_semantically_equals(canonical: &str, left: &Value, right: &Value) -> bool {
if left == right {
return true;
}
if let (Some(left_number), Some(right_number)) =
(policy_value_as_number(left), policy_value_as_number(right))
{
if canonical == "volumePercent" {
return (normalize_percent_ratio_for_alias_compare(left_number)
- normalize_percent_ratio_for_alias_compare(right_number))
.abs()
< 1e-12;
}
return (left_number - right_number).abs() < 1e-12;
}
if let (Some(left_text), Some(right_text)) = (left.as_str(), right.as_str()) {
return left_text.trim() == right_text.trim();
}
false
}
fn normalize_percent_ratio_for_alias_compare(value: f64) -> f64 {
if value > 1.0 { value / 100.0 } else { value }
}
fn policy_value_as_number(value: &Value) -> Option<f64> {
match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.trim().parse::<f64>().ok(),
_ => None,
}
}
fn reject_conflicting_policy_values(canonical: &str, values: &[Value]) -> Result<(), String> {
let Some(first) = values.first() else {
return Ok(());
};
if values
.iter()
.skip(1)
.any(|value| !policy_value_semantically_equals(canonical, first, value))
{
return Err(format!(
"riskPolicy.{canonical} has conflicting alias values"
));
}
Ok(())
}
fn push_blacklist_symbols_from_str(
raw: &str,
symbols: &mut Vec<Value>,
@@ -384,17 +457,25 @@ fn normalize_risk_policy_object_aliases(
for (canonical, aliases) in RISK_POLICY_BOOL_ALIAS_GROUPS {
let values = remove_policy_alias_values(object, canonical, aliases);
if !values.is_empty() {
let mut merged = false;
let mut parsed_values = Vec::with_capacity(values.len());
for value in values {
merged |= parse_policy_bool(canonical, value)?;
parsed_values.push(parse_policy_bool(canonical, value)?);
}
object.insert((*canonical).to_string(), Value::Bool(merged));
let first = parsed_values[0];
if parsed_values.iter().any(|value| *value != first) {
return Err(format!(
"riskPolicy.{canonical} has conflicting alias values"
));
}
object.insert((*canonical).to_string(), Value::Bool(first));
}
}
for (canonical, aliases) in RISK_POLICY_VALUE_ALIAS_GROUPS {
let values = remove_policy_alias_values(object, canonical, aliases);
if let Some(value) = values.into_iter().next() {
if !values.is_empty() {
reject_conflicting_policy_values(canonical, &values)?;
let value = values.into_iter().next().expect("non-empty values");
object.insert((*canonical).to_string(), value);
}
}
@@ -610,9 +691,23 @@ pub struct StrategyExpressionTradingConfig {
#[serde(default)]
pub daily_top_up: Option<bool>,
#[serde(default)]
pub daily_position_target_adjust: Option<bool>,
#[serde(default)]
pub rebalance_existing_positions: Option<bool>,
#[serde(default)]
pub selection_buffer_multiple: Option<f64>,
#[serde(default)]
pub retry_empty_rebalance: Option<bool>,
#[serde(default)]
pub weak_market_shrink_overweight_threshold: Option<f64>,
#[serde(
default,
alias = "max_hold_days",
alias = "max_holding_days",
alias = "maxHoldDays",
alias = "maxHoldingDays"
)]
pub max_holding_days: Option<i64>,
#[serde(default)]
pub delayed_limit_open_exit: Option<bool>,
#[serde(default)]
@@ -789,12 +884,15 @@ fn apply_flat_risk_overrides(
let value = normalize_percent_ratio(raw_value, "volume_percent")?;
cfg.risk_config.trading_constraints.volume_percent = value;
}
cfg.quote_quantity_limit = cfg.risk_config.trading_constraints.volume_limit_enabled
|| cfg.risk_config.trading_constraints.liquidity_limit_enabled
|| cfg.matching_type != MatchingType::MinuteLast;
sync_quote_quantity_limit(cfg);
Ok(())
}
fn sync_quote_quantity_limit(cfg: &mut PlatformExprStrategyConfig) {
cfg.quote_quantity_limit = cfg.risk_config.trading_constraints.liquidity_limit_enabled
|| cfg.matching_type != MatchingType::MinuteLast;
}
fn apply_risk_policy_overrides(
cfg: &mut PlatformExprStrategyConfig,
policy: Option<&StrategyRiskPolicySpec>,
@@ -909,6 +1007,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);
@@ -923,6 +1037,26 @@ fn parse_matching_type(value: Option<&str>) -> Result<Option<MatchingType>, Stri
}
}
fn parse_rebalance_cash_mode(value: Option<&str>) -> Result<Option<RebalanceCashMode>, String> {
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
return Ok(None);
};
match normalize_model_name(raw).as_str() {
"same_point_net" | "same_bar_net" | "same_execution_net" | "net_rebalance" | "net" => {
Ok(Some(RebalanceCashMode::SamePointNet))
}
"sell_then_buy" | "sell_first" | "sell_first_buy_later" | "live_like" => {
Ok(Some(RebalanceCashMode::SellThenBuy))
}
"pre_open_cash" | "existing_cash" | "cash_before_open" | "conservative" => {
Ok(Some(RebalanceCashMode::PreOpenCash))
}
_ => Err(format!(
"rebalanceCashMode only supports same_point_net, sell_then_buy, pre_open_cash: {raw}"
)),
}
}
fn parse_slippage_model(
model: Option<&str>,
value: Option<f64>,
@@ -935,7 +1069,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) {
@@ -950,13 +1084,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,
}
}
@@ -964,16 +1096,24 @@ fn parse_slippage_model(
fn apply_execution_behavior_overrides(
cfg: &mut PlatformExprStrategyConfig,
matching_type: Option<&str>,
rebalance_cash_mode: Option<&str>,
slippage_model: Option<&str>,
slippage_value: Option<f64>,
slippage_impact_coefficient: Option<f64>,
slippage_volatility_coefficient: Option<f64>,
slippage_max_value: Option<f64>,
sell_then_buy_delay_slippage_rate: Option<f64>,
strict_value_budget: Option<bool>,
) -> Result<(), String> {
if let Some(matching_type) = parse_matching_type(matching_type)? {
cfg.matching_type = matching_type;
}
if let Some(rebalance_cash_mode) = parse_rebalance_cash_mode(rebalance_cash_mode)? {
cfg.rebalance_cash_mode = rebalance_cash_mode;
}
if cfg.matching_type == MatchingType::MinuteLast {
cfg.rebalance_cash_mode = RebalanceCashMode::SellThenBuy;
}
if slippage_model.is_some()
|| slippage_value.is_some()
|| slippage_impact_coefficient.is_some()
@@ -993,6 +1133,26 @@ fn apply_execution_behavior_overrides(
if let Some(enabled) = strict_value_budget {
cfg.strict_value_budget = enabled;
}
if let Some(rate) = sell_then_buy_delay_slippage_rate {
if !rate.is_finite() || !(0.0..1.0).contains(&rate) {
return Err(
"sellThenBuyDelaySlippageRate must be a finite number in [0, 1)".to_string(),
);
}
if cfg.rebalance_cash_mode != RebalanceCashMode::SellThenBuy {
if rate != 0.0 {
return Err(
"sellThenBuyDelaySlippageRate can only be set when rebalanceCashMode is sell_then_buy"
.to_string(),
);
}
cfg.sell_then_buy_delay_slippage_rate = 0.0;
} else {
cfg.sell_then_buy_delay_slippage_rate = rate;
}
} else if cfg.rebalance_cash_mode != RebalanceCashMode::SellThenBuy {
cfg.sell_then_buy_delay_slippage_rate = 0.0;
}
Ok(())
}
@@ -1079,6 +1239,17 @@ fn stock_volume_ma_expr(days: usize) -> String {
}
}
fn normalize_index_throttle_exposure_expr(expr: &str) -> String {
let mut normalized = expr.to_string();
for suffix in ["short", "long", "5", "10", "20", "30"] {
normalized = normalized.replace(
&format!("benchmark_ma{suffix}"),
&format!("signal_ma{suffix}"),
);
}
normalized
}
fn infer_expression_windows(
cfg: &mut PlatformExprStrategyConfig,
benchmark_short_explicit: bool,
@@ -1090,6 +1261,7 @@ fn infer_expression_windows(
let mut benchmark_days = Vec::new();
for expr in [&cfg.exposure_expr, &cfg.buy_scale_expr] {
benchmark_days.extend(prefixed_ma_lookbacks(expr, "benchmark_ma"));
benchmark_days.extend(prefixed_ma_lookbacks(expr, "signal_ma"));
benchmark_days.extend(rolling_mean_lookbacks(expr, "benchmark_close"));
}
let benchmark_days = sorted_unique_positive(benchmark_days);
@@ -1170,6 +1342,9 @@ pub fn platform_expr_config_from_spec(
{
cfg.weak_market_shrink_overweight_threshold = Some(threshold);
}
if let Some(days) = engine.max_holding_days.filter(|value| *value > 0) {
cfg.max_holding_days = Some(days);
}
if let Some(schedule) = engine
.rebalance_schedule
.as_ref()
@@ -1255,11 +1430,13 @@ pub fn platform_expr_config_from_spec(
apply_execution_behavior_overrides(
&mut cfg,
engine.matching_type.as_deref(),
engine.rebalance_cash_mode.as_deref(),
engine.slippage_model.as_deref(),
engine.slippage_value,
engine.slippage_impact_coefficient,
engine.slippage_volatility_coefficient,
engine.slippage_max_value,
engine.sell_then_buy_delay_slippage_rate,
engine.strict_value_budget,
)?;
}
@@ -1394,7 +1571,16 @@ pub fn platform_expr_config_from_spec(
.as_ref()
.filter(|value| !value.trim().is_empty())
{
cfg.exposure_expr = expr.clone();
cfg.exposure_expr = if spec
.engine_config
.as_ref()
.and_then(|engine| engine.index_throttle.as_ref())
.is_some()
{
normalize_index_throttle_exposure_expr(expr)
} else {
expr.clone()
};
}
if let Some(expr) = risk
.stop_loss_expr
@@ -1448,6 +1634,18 @@ 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.rebalance_existing_positions {
cfg.rebalance_existing_positions = 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(enabled) = trading.retry_empty_rebalance {
cfg.retry_empty_rebalance = enabled;
}
@@ -1457,6 +1655,9 @@ pub fn platform_expr_config_from_spec(
{
cfg.weak_market_shrink_overweight_threshold = Some(threshold);
}
if let Some(days) = trading.max_holding_days.filter(|value| *value > 0) {
cfg.max_holding_days = Some(days);
}
if let Some(enabled) = trading.release_slot_on_exit_signal {
cfg.release_slot_on_exit_signal = enabled;
}
@@ -1567,7 +1768,7 @@ pub fn platform_expr_config_from_spec(
let defensive = index_throttle.defensive_exposure.unwrap_or(0.5);
let full = index_throttle.full_exposure.unwrap_or(1.0);
cfg.exposure_expr = format!(
"benchmark_ma_short < benchmark_ma_long * {} ? {} : {}",
"signal_ma_short < signal_ma_long * {} ? {} : {}",
ratio, defensive, full
);
}
@@ -1601,14 +1802,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 = is_aiquant_profile(
spec.engine_config
.as_ref()
.and_then(|engine| engine.profile_name.as_deref()),
);
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()
@@ -1665,13 +1868,16 @@ pub fn platform_expr_config_from_spec(
apply_execution_behavior_overrides(
&mut cfg,
execution.matching_type.as_deref(),
execution.rebalance_cash_mode.as_deref(),
execution.slippage_model.as_deref(),
execution.slippage_value,
execution.slippage_impact_coefficient,
execution.slippage_volatility_coefficient,
execution.slippage_max_value,
execution.sell_then_buy_delay_slippage_rate,
execution.strict_value_budget,
)?;
sync_quote_quantity_limit(&mut cfg);
}
if cfg.aiquant_transaction_cost
&& cfg
@@ -1717,7 +1923,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
@@ -2158,7 +2372,10 @@ 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"]);
assert_eq!(
cfg.universe_exclude,
["paused", "st", "kcb", "one_yuan", "bjse"]
);
assert!(!cfg.rotation_enabled);
assert!(cfg.daily_top_up_enabled);
assert!(cfg.retry_empty_rebalance);
@@ -2185,6 +2402,24 @@ mod tests {
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.2));
}
#[test]
fn parses_max_holding_days_from_engine_and_runtime_trading() {
let spec = serde_json::json!({
"engineConfig": {
"maxHoldDays": 120
},
"runtimeExpressions": {
"trading": {
"max_holding_days": 90
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.max_holding_days, Some(90));
}
#[test]
fn parses_signal_dates_rebalance_into_platform_config() {
let spec = serde_json::json!({
@@ -2207,6 +2442,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!({
@@ -2309,6 +2564,24 @@ mod tests {
assert!(cfg.quote_quantity_limit);
}
#[test]
fn volume_limit_does_not_enable_quote_quantity_limit_for_minute_last() {
let spec = serde_json::json!({
"execution": {
"matchingType": "minute_last",
"volumeLimit": true,
"liquidityLimit": false,
"volumePercent": 0.25
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(cfg.risk_config.trading_constraints.volume_limit_enabled);
assert!(!cfg.risk_config.trading_constraints.liquidity_limit_enabled);
assert!(!cfg.quote_quantity_limit);
}
#[test]
fn parses_st_and_star_st_risk_policy_switches_into_platform_config() {
let spec = serde_json::json!({
@@ -2423,6 +2696,63 @@ mod tests {
);
}
#[test]
fn accepts_equivalent_risk_policy_alias_values() {
let spec = serde_json::json!({
"engineConfig": {
"riskPolicy": {
"rejectStBuy": false,
"reject_st_buy": 0,
"volumePercent": 0.25,
"volume_percent": 25,
"minimumCommission": 5,
"minimum_commission": "5"
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert!(!cfg.risk_config.static_rules.reject_st_buy);
assert!((cfg.risk_config.trading_constraints.volume_percent - 0.25).abs() < 1e-12);
assert_eq!(cfg.risk_config.trading_constraints.minimum_commission, 5.0);
}
#[test]
fn rejects_conflicting_risk_policy_alias_values() {
let bool_conflict = serde_json::json!({
"engineConfig": {
"riskPolicy": {
"rejectStBuy": false,
"reject_st_buy": true
}
}
});
let bool_error = platform_expr_config_from_value("", "", &bool_conflict)
.expect_err("conflicting bool aliases must fail");
assert!(
bool_error
.to_string()
.contains("riskPolicy.rejectStBuy has conflicting alias values")
);
let value_conflict = serde_json::json!({
"engineConfig": {
"riskPolicy": {
"minimumCommission": 5,
"minimum_commission": 6
}
}
});
let value_error = platform_expr_config_from_value("", "", &value_conflict)
.expect_err("conflicting value aliases must fail");
assert!(
value_error
.to_string()
.contains("riskPolicy.minimumCommission has conflicting alias values")
);
}
#[test]
fn parses_execution_slippage_overrides_into_platform_config() {
let spec = serde_json::json!({
@@ -2448,6 +2778,78 @@ 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!({
"execution": {
"matchingType": "next_bar_open",
"rebalanceCashMode": "pre_open_cash"
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(cfg.matching_type, MatchingType::NextBarOpen);
assert_eq!(cfg.rebalance_cash_mode, RebalanceCashMode::PreOpenCash);
assert_eq!(cfg.sell_then_buy_delay_slippage_rate, 0.0);
let invalid_delay_spec = serde_json::json!({
"execution": {
"matchingType": "next_bar_open",
"rebalanceCashMode": "pre_open_cash",
"sellThenBuyDelaySlippageRate": 0.003
}
});
let invalid_delay = platform_expr_config_from_value("", "", &invalid_delay_spec)
.expect_err("delay slippage should be rejected outside sell_then_buy");
assert!(invalid_delay.to_string().contains("can only be set"));
let override_to_pre_open_spec = serde_json::json!({
"engineConfig": {
"rebalanceCashMode": "sell_then_buy",
"sellThenBuyDelaySlippageRate": 0.003
},
"execution": {
"matchingType": "next_bar_open",
"rebalanceCashMode": "pre_open_cash"
}
});
let override_to_pre_open =
platform_expr_config_from_value("", "", &override_to_pre_open_spec).expect("config");
assert_eq!(
override_to_pre_open.rebalance_cash_mode,
RebalanceCashMode::PreOpenCash
);
assert_eq!(override_to_pre_open.sell_then_buy_delay_slippage_rate, 0.0);
let minute_spec = serde_json::json!({
"execution": {
"matchingType": "minute_last",
"rebalanceCashMode": "pre_open_cash"
}
});
let minute_cfg = platform_expr_config_from_value("", "", &minute_spec).expect("config");
assert_eq!(minute_cfg.matching_type, MatchingType::MinuteLast);
assert_eq!(
minute_cfg.rebalance_cash_mode,
RebalanceCashMode::SellThenBuy
);
}
#[test]
fn platform_spec_accepts_only_supported_matching_types() {
for (raw, expected) in [
@@ -2561,6 +2963,9 @@ mod tests {
"runtimeExpressions": {
"trading": {
"dailyTopUp": false,
"dailyPositionTargetAdjust": false,
"rebalanceExistingPositions": true,
"selectionBufferMultiple": 1.5,
"retryEmptyRebalance": false
}
}
@@ -2569,6 +2974,9 @@ mod tests {
let cfg = platform_expr_config_from_value("", "", &explicit_off).expect("config");
assert!(!cfg.daily_top_up_enabled);
assert!(!cfg.daily_position_target_adjust_enabled);
assert!(cfg.rebalance_existing_positions);
assert_eq!(cfg.selection_buffer_multiple, 1.5);
assert!(!cfg.retry_empty_rebalance);
}
@@ -2656,6 +3064,40 @@ mod tests {
assert_eq!(cfg.stock_long_ma_days, 21);
}
#[test]
fn index_throttle_uses_signal_ma_not_performance_benchmark_ma() {
let spec = serde_json::json!({
"signalSymbol": "000852.SH",
"benchmark": {
"instrumentId": "932000.CSI"
},
"engineConfig": {
"profileName": "aiquant",
"indexThrottle": {
"shortDays": 10,
"longDays": 30,
"defensiveExposure": 0.5,
"fullExposure": 1.0
}
},
"runtimeExpressions": {
"risk": {
"exposureExpr": "if benchmark_ma10 > benchmark_ma30 { 1.0 } else { 0.5 }"
}
}
});
let cfg = platform_expr_config_from_value("strategy88", "", &spec).expect("config");
assert_eq!(cfg.signal_symbol, "000852.SH");
assert_eq!(
cfg.exposure_expr,
"if signal_ma10 > signal_ma30 { 1.0 } else { 0.5 }"
);
assert_eq!(cfg.benchmark_short_ma_days, 10);
assert_eq!(cfg.benchmark_long_ma_days, 30);
}
#[test]
fn parses_daily_schedule_time_for_aiquant_execution_quotes() {
let spec = serde_json::json!({
@@ -2699,6 +3141,32 @@ mod tests {
);
}
#[test]
fn parses_aiquant_compatibility_profile_for_delayed_limit_exit() {
let spec = serde_json::json!({
"engineConfig": {
"profileName": "cn_a_microcap_v1",
"compatibilityProfile": "aiquant_rqalpha"
},
"rebalance": { "tradeTimes": ["09:31", "10:15"] },
"runtimeExpressions": {
"schedule": { "frequency": "daily", "time": "10:15" }
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
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 parses_explicit_delayed_limit_open_exit() {
let spec = serde_json::json!({
+98 -5
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();
}
@@ -117,6 +130,7 @@ impl Position {
let mut remaining = quantity;
let mut realized = 0.0;
let mut realized_entry = 0.0;
let average_cost_before_sell = self.average_cost;
while remaining > 0 {
let Some(first_lot) = self.lots.first_mut() else {
@@ -141,7 +155,13 @@ impl Position {
self.day_trade_quantity_delta -= quantity as i32;
self.day_sell_quantity += quantity;
self.day_sell_value += execution_price * quantity as f64;
self.recalculate_average_cost();
if self.quantity == 0 {
self.recalculate_average_cost();
} else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 {
self.average_cost = average_cost_before_sell;
} else {
self.recalculate_average_cost();
}
self.refresh_day_pnl();
Ok(realized)
}
@@ -252,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();
@@ -370,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
@@ -837,6 +865,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
@@ -872,6 +901,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;
@@ -879,7 +910,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 {
@@ -955,6 +1009,45 @@ mod tests {
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
}
#[test]
fn partial_sell_preserves_remaining_average_cost() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("603958.SH");
position.buy(date, 800, 18.0981);
position.record_buy_trade_cost(800, 5.0);
position.buy(date, 1700, 19.4694);
position.record_buy_trade_cost(1700, 8.27451625);
position.buy(date, 200, 18.4584);
position.record_buy_trade_cost(200, 5.0);
position.buy(date, 100, 17.8378);
position.record_buy_trade_cost(100, 5.0);
let average_cost_before = position.average_cost;
position.sell(2700, 16.8331).expect("partial sell");
assert_eq!(position.quantity, 100);
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();
+110 -48
View File
@@ -45,27 +45,27 @@ pub struct StaticRiskRuleConfig {
impl Default for StaticRiskRuleConfig {
fn default() -> Self {
Self {
reject_st_selection: true,
reject_st_selection: false,
reject_st_buy: true,
reject_star_st_selection: true,
reject_star_st_selection: false,
reject_star_st_buy: true,
reject_paused_selection: true,
reject_paused_selection: false,
reject_paused_buy: true,
reject_paused_sell: true,
reject_inactive_selection: true,
reject_inactive_selection: false,
reject_inactive_buy: true,
reject_inactive_sell: true,
reject_new_listing_selection: true,
reject_new_listing_selection: false,
reject_new_listing_buy: true,
reject_kcb_selection: true,
reject_kcb_selection: false,
reject_kcb_buy: true,
reject_bjse_selection: true,
reject_bjse_selection: false,
reject_bjse_buy: true,
reject_one_yuan_selection: true,
reject_one_yuan_selection: false,
reject_one_yuan_buy: true,
respect_allow_buy_sell: true,
reject_upper_limit_selection: true,
reject_lower_limit_selection: 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,
@@ -223,6 +223,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,
@@ -255,11 +270,6 @@ impl ChinaAShareRiskControl {
) {
return Some(reason);
}
if config.static_rules.respect_allow_buy_sell
&& (!candidate.allow_buy || !candidate.allow_sell)
{
return Some("trade_disabled");
}
let selection_price = market.price(PriceField::Last);
if config.static_rules.reject_upper_limit_selection
&& market.is_at_upper_limit_price(selection_price)
@@ -506,10 +516,9 @@ impl ChinaAShareRiskControl {
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
return Some("paused");
}
// `allow_sell` is derived from the daily candidate snapshot and may
// reflect an open/close fallback rather than the actual execution price.
// A sell order must be blocked by the execution price lower-limit check
// below, while suspension and delisting are handled above.
if config.static_rules.respect_allow_buy_sell && !candidate.allow_sell {
return Some("sell_disabled");
}
if config.static_rules.reject_lower_limit_sell
&& market.is_at_lower_limit_price(check_price)
{
@@ -590,8 +599,7 @@ fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlCon
|| config.static_rules.reject_bjse_selection
|| config.static_rules.reject_one_yuan_selection
|| config.static_rules.reject_upper_limit_selection
|| config.static_rules.reject_lower_limit_selection
|| config.static_rules.respect_allow_buy_sell;
|| config.static_rules.reject_lower_limit_selection;
}
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
}
@@ -687,15 +695,13 @@ fn missing_single_field_rejected(
RiskCheckScope::Sell => false,
},
"allow_buy" => match scope {
RiskCheckScope::Selection | RiskCheckScope::Buy => {
config.static_rules.respect_allow_buy_sell
}
RiskCheckScope::Selection => false,
RiskCheckScope::Buy => config.static_rules.respect_allow_buy_sell,
RiskCheckScope::Sell => false,
},
"allow_sell" => match scope {
RiskCheckScope::Selection | RiskCheckScope::Sell => {
config.static_rules.respect_allow_buy_sell
}
RiskCheckScope::Selection => false,
RiskCheckScope::Sell => config.static_rules.respect_allow_buy_sell,
RiskCheckScope::Buy => false,
},
"upper_limit" | "upper_limit_price" | "high_limit" | "high_limit_price" => match scope {
@@ -720,7 +726,6 @@ fn missing_single_field_rejected(
|| config.static_rules.reject_one_yuan_selection
|| config.static_rules.reject_upper_limit_selection
|| config.static_rules.reject_lower_limit_selection
|| config.static_rules.respect_allow_buy_sell
}
RiskCheckScope::Buy => {
config.static_rules.reject_st_buy
@@ -809,7 +814,7 @@ mod tests {
}
#[test]
fn sell_rejection_uses_execution_price_not_stale_allow_sell() {
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
let prev_date = d(2024, 4, 16);
let date = d(2024, 4, 17);
let candidate = candidate(date);
@@ -825,14 +830,29 @@ mod tests {
6.27,
);
assert_eq!(reason, None);
assert_eq!(reason, Some("sell_disabled"));
let mut relaxed = FidcRiskControlConfig::default();
relaxed.static_rules.respect_allow_buy_sell = false;
let relaxed_reason = ChinaAShareRiskControl::sell_rejection_reason_with_config(
date,
&candidate,
&market,
None,
Some(&position),
6.27,
&relaxed,
);
assert_eq!(relaxed_reason, None);
}
#[test]
fn sell_rejection_blocks_execution_price_at_lower_limit() {
let prev_date = d(2024, 4, 16);
let date = d(2024, 4, 17);
let candidate = candidate(date);
let mut candidate = candidate(date);
candidate.allow_sell = true;
let market = market(date, 5.63, 5.63);
let position = position(prev_date);
@@ -879,13 +899,19 @@ mod tests {
let default_reason =
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_kcb_selection = true;
let enabled_selection_reason =
ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
);
config.static_rules.reject_kcb_selection = false;
config.static_rules.reject_kcb_buy = false;
let configured_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
date, &candidate, &market, None, 6.27, &config,
);
assert_eq!(default_reason, Some("kcb"));
assert_eq!(default_reason, None);
assert_eq!(enabled_selection_reason, Some("kcb"));
assert_eq!(configured_reason, None);
}
@@ -994,6 +1020,10 @@ mod tests {
let default_buy =
ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.27);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_bjse_selection = true;
let enabled_selection = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
);
config.static_rules.reject_bjse_selection = false;
config.static_rules.reject_bjse_buy = false;
let configured_selection = ChinaAShareRiskControl::selection_rejection_reason_with_config(
@@ -1003,8 +1033,9 @@ mod tests {
date, &candidate, &market, None, 6.27, &config,
);
assert_eq!(default_selection, Some("bjse"));
assert_eq!(default_selection, None);
assert_eq!(default_buy, Some("bjse"));
assert_eq!(enabled_selection, Some("bjse"));
assert_eq!(configured_selection, None);
assert_eq!(configured_buy, None);
}
@@ -1016,13 +1047,11 @@ mod tests {
candidate.symbol = "688506.SH".to_string();
candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_kcb_selection = true;
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
date,
&candidate,
&market,
None,
&FidcRiskControlConfig::default(),
date, &candidate, &market, None, &config,
)
.expect("kcb selection rejection");
@@ -1037,9 +1066,13 @@ mod tests {
candidate.allow_sell = true;
candidate.risk_level_code = Some("inactive_or_delisted".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_inactive_selection = true;
assert_eq!(
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None),
ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config
),
Some("inactive_or_delisted")
);
assert_eq!(
@@ -1065,9 +1098,10 @@ mod tests {
}
#[test]
fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() {
fn missing_risk_state_default_selection_ignores_allow_flags_but_buy_rejects() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.allow_sell = true;
candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string());
let market = market(date, 6.27, 5.63);
@@ -1084,11 +1118,28 @@ mod tests {
6.27,
);
assert_eq!(selection_reason, Some("missing_risk_state"));
assert_eq!(selection_reason, None);
assert_eq!(buy_reason, Some("missing_risk_state"));
assert_eq!(sell_reason, None);
}
#[test]
fn missing_risk_state_selection_still_respects_configured_static_filters() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
candidate.allow_sell = true;
candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_st_selection = true;
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
);
assert_eq!(selection_reason, Some("missing_risk_state"));
}
#[test]
fn missing_risk_state_selection_audit_keeps_missing_fields_in_reason() {
let date = d(2025, 1, 2);
@@ -1097,12 +1148,11 @@ mod tests {
Some("missing_risk_state:is_st,allow_buy,upper_limit_price".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_st_selection = true;
config.static_rules.reject_upper_limit_selection = true;
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
date,
&candidate,
&market,
None,
&FidcRiskControlConfig::default(),
date, &candidate, &market, None, &config,
)
.expect("missing risk state rejection");
@@ -1174,6 +1224,8 @@ mod tests {
Some("missing_risk_state:upper_limit_price,lower_limit_price".to_string());
let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_upper_limit_selection = true;
config.static_rules.reject_lower_limit_selection = true;
config.static_rules.reject_upper_limit_selection = false;
let upper_disabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
@@ -1267,12 +1319,17 @@ mod tests {
let default_reason =
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_upper_limit_selection = true;
let enabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
);
config.static_rules.reject_upper_limit_selection = false;
let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
);
assert_eq!(default_reason, Some("upper_limit"));
assert_eq!(default_reason, None);
assert_eq!(enabled_reason, Some("upper_limit"));
assert_eq!(configured_reason, None);
}
@@ -1285,12 +1342,17 @@ mod tests {
let default_reason =
ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_lower_limit_selection = true;
let enabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
);
config.static_rules.reject_lower_limit_selection = false;
let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config,
);
assert_eq!(default_reason, Some("lower_limit"));
assert_eq!(default_reason, None);
assert_eq!(enabled_reason, Some("lower_limit"));
assert_eq!(configured_reason, None);
}
}
+78 -54
View File
@@ -2035,7 +2035,6 @@ impl OmniMicroCapStrategy {
Some(fill.quantity)
}
#[allow(dead_code)]
fn projected_market_fillable_quantity(
&self,
ctx: &StrategyContext<'_>,
@@ -2047,18 +2046,19 @@ impl OmniMicroCapStrategy {
minimum_order_quantity: u32,
order_step_size: u32,
allow_odd_lot_sell: bool,
current_fill_quantity: u32,
execution_state: &ProjectedExecutionState,
) -> Option<u32> {
if requested_qty == 0 {
return Some(0);
}
let snapshot = ctx.data.market(date, symbol)?;
if snapshot.minute_volume == 0 {
let constraints = self.config.risk_config.trading_constraints;
if constraints.volume_limit_enabled && snapshot.minute_volume == 0 {
return None;
}
let mut max_fill = requested_qty;
let constraints = self.config.risk_config.trading_constraints;
if constraints.liquidity_limit_enabled {
let top_level_liquidity = match side {
OrderSide::Buy => snapshot.liquidity_for_buy(),
@@ -2080,9 +2080,14 @@ impl OmniMicroCapStrategy {
max_fill = max_fill.min(liquidity_limited);
}
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
let consumed_turnover = execution_state
.intraday_turnover
.get(symbol)
.copied()
.unwrap_or(0)
.saturating_add(current_fill_quantity);
if constraints.volume_limit_enabled {
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round()
let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor()
as i64
- consumed_turnover as i64;
if raw_limit <= 0 {
@@ -2130,6 +2135,23 @@ impl OmniMicroCapStrategy {
return None;
}
let requested_qty = self.projected_market_fillable_quantity(
ctx,
date,
symbol,
side,
requested_qty,
round_lot,
minimum_order_quantity,
order_step_size,
allow_odd_lot_sell,
0,
execution_state,
)?;
if requested_qty == 0 {
return None;
}
if let Some(market) = ctx.data.market(date, symbol) {
let execution_price = self.projected_execution_price(market, side);
if execution_price.is_finite() && execution_price > 0.0 {
@@ -2223,7 +2245,25 @@ impl OmniMicroCapStrategy {
if remaining_qty == 0 {
break;
}
let mut take_qty = remaining_qty.min(available_qty);
let market_fillable_qty = self
.projected_market_fillable_quantity(
ctx,
date,
symbol,
side,
remaining_qty,
round_lot,
minimum_order_quantity,
order_step_size,
allow_odd_lot_sell,
filled_qty,
execution_state,
)
.unwrap_or(0);
if market_fillable_qty == 0 {
break;
}
let mut take_qty = remaining_qty.min(available_qty).min(market_fillable_qty);
if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
take_qty =
self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size);
@@ -2474,11 +2514,7 @@ impl OmniMicroCapStrategy {
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
defer_selection_risk: bool,
) -> Vec<FidcRiskDecisionAudit> {
if defer_selection_risk {
return Vec::new();
}
let mut decisions = Vec::new();
for factor in ctx.data.factor_snapshots_on(date) {
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) {
@@ -2538,13 +2574,8 @@ impl OmniMicroCapStrategy {
date: NaiveDate,
band_low: f64,
band_high: f64,
defer_selection_risk: bool,
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
let universe = if defer_selection_risk {
ctx.fundamental_universe_on(date)
} else {
ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config)
};
let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config);
let mut diagnostics = Vec::new();
let mut selected = Vec::new();
let start = lower_bound_eligible(&universe, band_low);
@@ -2553,12 +2584,8 @@ impl OmniMicroCapStrategy {
if candidate.market_cap_bn > band_high {
break;
}
let rejection = if defer_selection_risk {
(!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
.then_some("ma_filter".to_string())
} else {
self.buy_rejection_reason(ctx, date, &candidate.symbol)?
};
let rejection = (!self.stock_passes_ma_filter(ctx, date, &candidate.symbol))
.then_some("ma_filter".to_string());
if let Some(reason) = rejection {
if diagnostics.len() < 12 {
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
@@ -2584,7 +2611,7 @@ impl Strategy for OmniMicroCapStrategy {
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
let signal_date = ctx.decision_date;
let execution_date = ctx.execution_date;
let defer_selection_risk = ctx.is_lagged_execution();
let lagged_execution = ctx.is_lagged_execution();
if self.config.in_skip_window(signal_date) {
return Ok(StrategyDecision {
rebalance: false,
@@ -2595,9 +2622,12 @@ impl Strategy for OmniMicroCapStrategy {
.positions()
.keys()
.cloned()
.map(|symbol| OrderIntent::TargetValue {
.map(|symbol| OrderIntent::TimedTargetValue {
symbol,
target_value: 0.0,
style: AlgoOrderStyle::Twap,
start_time: Some(self.intraday_execution_start_time()),
end_time: Some(self.intraday_execution_start_time()),
reason: "seasonal_stop_window".to_string(),
})
.collect(),
@@ -2630,8 +2660,8 @@ impl Strategy for OmniMicroCapStrategy {
// 使用前一交易日的指数价格计算市值区间(模拟实盘场景)
let (band_low, band_high) = self.market_cap_band(prev_index_level);
let (stock_list, selection_notes) =
self.select_symbols(ctx, signal_date, band_low, band_high, defer_selection_risk)?;
let risk_decisions = self.selection_risk_decisions(ctx, signal_date, defer_selection_risk);
self.select_symbols(ctx, signal_date, band_low, band_high)?;
let risk_decisions = self.selection_risk_decisions(ctx, signal_date);
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
let projection_date = signal_date;
let mut projected = ctx.portfolio.clone();
@@ -2656,8 +2686,8 @@ impl Strategy for OmniMicroCapStrategy {
<= position.average_cost * self.config.stop_loss_ratio
+ self.stop_loss_tolerance(market);
let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio;
let can_sell = defer_selection_risk
|| self.can_sell_position(ctx, execution_date, &position.symbol);
let can_sell =
lagged_execution || self.can_sell_position(ctx, execution_date, &position.symbol);
let at_upper_limit = market.is_at_upper_limit_price(current_price);
if stop_hit || (profit_hit && !at_upper_limit) {
let sell_reason = if stop_hit {
@@ -2693,7 +2723,7 @@ impl Strategy for OmniMicroCapStrategy {
{
continue;
}
if !defer_selection_risk
if !lagged_execution
&& self
.buy_rejection_reason(ctx, execution_date, symbol)?
.is_some()
@@ -2731,7 +2761,7 @@ impl Strategy for OmniMicroCapStrategy {
if stock_list.iter().any(|candidate| candidate == symbol) {
continue;
}
if !defer_selection_risk && !self.can_sell_position(ctx, execution_date, symbol) {
if !lagged_execution && !self.can_sell_position(ctx, execution_date, symbol) {
continue;
}
order_intents.push(OrderIntent::TargetValue {
@@ -2759,7 +2789,7 @@ impl Strategy for OmniMicroCapStrategy {
{
continue;
}
if !defer_selection_risk
if !lagged_execution
&& self
.buy_rejection_reason(ctx, execution_date, symbol)?
.is_some()
@@ -2990,33 +3020,27 @@ mod tests {
default_cfg.stock_long_ma_days = 3;
let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone());
let (default_selected, _) = default_strategy
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
.select_symbols(&ctx, dates[2], 0.0, 100.0)
.expect("default selection");
assert!(default_selected.is_empty());
let default_risk_decisions =
default_strategy.selection_risk_decisions(&ctx, dates[2], false);
assert_eq!(default_risk_decisions.len(), 1);
assert_eq!(default_risk_decisions[0].symbol, symbol);
assert_eq!(default_risk_decisions[0].rule_code, "kcb");
assert_eq!(default_selected, vec![symbol.to_string()]);
let default_risk_decisions = default_strategy.selection_risk_decisions(&ctx, dates[2]);
assert!(default_risk_decisions.is_empty());
let mut cfg = default_cfg;
cfg.risk_config.static_rules.reject_kcb_selection = true;
let strict_strategy = OmniMicroCapStrategy::new(cfg);
let (selected, _) = strict_strategy
.select_symbols(&ctx, dates[2], 0.0, 100.0)
.expect("strict selection");
assert!(selected.is_empty());
let strict_risk_decisions = strict_strategy.selection_risk_decisions(&ctx, dates[2]);
assert_eq!(strict_risk_decisions.len(), 1);
assert_eq!(strict_risk_decisions[0].symbol, symbol);
assert_eq!(strict_risk_decisions[0].rule_code, "kcb");
assert!(
default_risk_decisions[0]
strict_risk_decisions[0]
.diagnostic_line()
.starts_with("risk_decision=")
);
let mut cfg = default_cfg;
cfg.risk_config.static_rules.reject_kcb_selection = false;
cfg.risk_config.static_rules.reject_kcb_buy = false;
let configured_strategy = OmniMicroCapStrategy::new(cfg);
let (selected, _) = configured_strategy
.select_symbols(&ctx, dates[2], 0.0, 100.0, false)
.expect("configured selection");
assert!(
configured_strategy
.selection_risk_decisions(&ctx, dates[2], false)
.is_empty()
);
assert_eq!(selected, vec![symbol.to_string()]);
}
}
+61 -15
View File
@@ -72,6 +72,29 @@ pub struct StrategyAiCatalog {
pub data_lake_fields: Vec<ManualFactorSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyAiHoldingCountContract {
#[serde(
default,
alias = "holdingCount",
alias = "holding_count",
alias = "targetHoldingCount",
alias = "target_holding_count"
)]
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<i64>,
#[serde(
default,
alias = "kind",
alias = "holdingCountMode",
alias = "holding_count_mode",
alias = "targetHoldingCountMode",
alias = "target_holding_count_mode"
)]
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyAiGenerateRequest {
pub user_goal: String,
@@ -79,6 +102,9 @@ pub struct StrategyAiGenerateRequest {
pub market: String,
pub benchmark_symbol: String,
pub signal_symbol: String,
#[serde(default, alias = "holdingCountContract")]
#[serde(skip_serializing_if = "Option::is_none")]
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -87,9 +113,14 @@ pub struct StrategyAiOptimizeRequest {
pub objective: String,
pub result_summary: serde_json::Value,
pub diagnostics: Vec<String>,
#[serde(default, alias = "holdingCountContract")]
#[serde(skip_serializing_if = "Option::is_none")]
pub holding_count_contract: Option<StrategyAiHoldingCountContract>,
}
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益 >= 150% 视为满足收益目标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
const DEFAULT_THREE_YEAR_RETURN_TARGET_PROMPT: &str = "默认收益目标:用户没有明确指定更高收益阈值时,三年回测区间策略总收益严格 > 120% 视为满足收益目标120.00% 本身不算达标;达到该阈值后可以继续优化夏普、回撤、换手和稳定性,但不得把已达标策略判为失败或为了追更高收益破坏无未来数据、持仓数量和同条件对账合同。";
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\"";
pub fn built_in_strategy_manual() -> StrategyAiManual {
StrategyAiManual {
@@ -104,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 当成下单前已知信息。".to_string(),
"next_bar_open 会用决策日信号生成订单,并在下一可交易开盘撮合;不得把执行日 open/high/low/close 当成下单前已知信息;涨停买入和跌停卖出风控必须用实际 next-open 成交价比较,不能用执行日 close/last 或 next-close".to_string(),
"自定义 fn 必须通过参数传入运行时字段;不要用 fn score() 这类零参数函数直接引用 market_cap、close、ma5 等股票字段。".to_string(),
"禁止自由 Python/JavaScript 命令式语句,最终必须输出平台 DSL。".to_string(),
],
@@ -219,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、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。PG/Source Lake 是真相源,Redis 只可做当日锁、热配置缓存和配置变更通知。".to_string(),
},
ManualSection {
title: "corporate_actions.dividend_reinvestment".to_string(),
@@ -227,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 解释为下单前已知数据。分钟线回测使用当前分钟价格成交,只能写 execution.matching_type(\"minute_last\");不要把 vwap、twap、open_auction、minute_best_own、minute_best_counterparty 写成全局 matching_type,这些只属于显式订单或内部撮合能力。滑点支持 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 使用决策日信号并在下一可交易日 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(),
},
ManualSection {
title: "期货提交校验".to_string(),
@@ -335,13 +366,13 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
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: "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。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".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: "任意数值字段滚动求和、最小值、最大值。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
ManualFunction { name: "rolling_stddev / stddev / rolling_zscore / pct_change".to_string(), signature: "stddev(\"close\", 20) / pct_change(\"close\", 10)".to_string(), detail: "滚动标准差、最新值 Z 分数和区间涨跌幅。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用;需要收益率波动时先使用已注册收益率字段或发布因子,不要写 rolling_stddev(pct_change(\"close\", 1), 20)。pct_change(field, n) 会读取 n+1 个窗口点并计算 latest / first - 1。".to_string() },
ManualFunction { name: "Source Lake 指标因子".to_string(), signature: "factor_value(\"ths_valid_turnover_stock\", 1)".to_string(), detail: "Strategy Factory Source Lake 中已完成 PIT/as-of 审计的 source rows 字段、已发布指标或因子 artifact 会进入 extra_factors,可用 factor(\"字段\")、factors[\"字段\"]、factor_value(\"字段\", lookback) 或 rolling_mean(\"字段\", n) 读取。市值类指标统一提供亿元口径别名 ths_market_value_stock、ths_market_value_stock_bn、ths_current_mv_stock、ths_current_mv_stock_bn,同时保留 raw 后缀原始值。".to_string() },
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs) / safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法,两参数形式默认 fallback=0".to_string() },
ManualFunction { name: "contains/starts_with/ends_with/lower/upper/trim/strlen".to_string(), signature: "starts_with(symbol, \"60\")".to_string(), detail: "字符串辅助函数。".to_string() },
],
factor_sources: vec![
@@ -439,11 +470,12 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
out.push_str("## AI 代码生成硬约束\n");
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.slippage`、`universe.exclude`。\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("- 禁止伪 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");
out.push_str("- `rolling_mean`、`rolling_sum/min/max/stddev/zscore`、`pct_change`、`factor_value` 等 helper 的第一个参数必须是字段名或字符串字段名;不要输出 `rolling_stddev(pct_change(\"close\", 1), 20)` 这类嵌套表达式。\n");
out.push_str("- 自定义 `fn` 必须通过参数传入运行时字段;不要用 `fn score()` 这类零参数函数直接引用 `market_cap`、`close`、`ma5` 等股票字段。\n");
out.push_str("- `selection.market_cap_band` 必须写命名参数:`field=\"market_cap\"` 或 `field=\"free_float_cap\"`,并包含 `lower=...` 与 `upper=...`。\n");
out.push_str(
@@ -452,7 +484,7 @@ pub fn render_manual_markdown(manual: &StrategyAiManual) -> String {
out.push_str("- `filter.stock_expr(...)` 只写 alpha 或业务过滤条件;不要把 `!is_st`、`!paused`、`!at_upper_limit`、`!at_lower_limit` 这类基础风控散落在过滤表达式里。\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、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
out.push_str("- `next_bar_open` 必须区分信号日、订单创建日和实际成交日:T 日只生成订单意图,涨跌停、停牌、ST、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断;涨停买入和跌停卖出必须比较实际 next-open 成交价与涨跌停价,不能用执行日 close/last 或 next-close禁止用 T 日执行状态拦截 T+1 可交易订单。\n");
out.push_str("- `execution.matching_type(...)` 和 `execution.slippage(...)` 必须使用手册列出的合法取值。\n\n");
out.push_str("## 语句块\n");
for item in &manual.statement_blocks {
@@ -532,9 +564,11 @@ pub fn build_generation_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("参数形态必须严格: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);不要生成 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 基础风控、成交量和交易成本命名参数,例如 reject_st_selection=true、reject_st_buy=true、reject_star_st_selection=true、reject_star_st_buy=true、reject_paused_selection=true、reject_paused_buy=true、reject_paused_sell=true、reject_inactive_selection=true、reject_new_listing_selection=true、reject_kcb_selection=true、reject_bjse_selection=true、reject_one_yuan_selection=true、forbid_same_day_rebuy_after_sell=true、reject_upper_limit_selection=true、reject_lower_limit_selection=true、reject_upper_limit_buy=true、reject_lower_limit_sell=true、blacklist_enabled=true、volume_limit_enabled=true、liquidity_limit_enabled=true、volume_percent=0.25、commission_rate=0.0003、minimum_commission=5、stamp_tax_rate_after_change=0.0005,不要用它表达策略择时或收益规则;完整三元表达式 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、退市、一元股、黑名单、成交量和盘口流动性等执行约束必须由撮合/风控层按实际成交日判断禁止用 T 日执行状态拦截 T+1 可交易订单;execution.slippage 必须写 execution.slippage(\"none\") 或 execution.slippage(\"price_ratio\", 0.001)。\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("回测成功但 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(reject_st_selection=true, reject_st_buy=true, reject_star_st_selection=true, reject_star_st_buy=true, reject_paused_selection=true, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=true, reject_new_listing_selection=true, reject_kcb_selection=true, reject_bjse_selection=true, reject_one_yuan_selection=true, reject_upper_limit_selection=true, reject_lower_limit_selection=true, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=true, volume_limit_enabled=true, liquidity_limit_enabled=true, volume_percent=0.25, commission_rate=0.0003, minimum_commission=5)\nrisk.index_exposure(1.0)\nrisk.stop_loss(holding_return < -0.08)\nexecution.slippage(\"price_ratio\", 0.001)\n}\n\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("用户目标:\n");
prompt.push_str(&format!("- {}\n", request.user_goal));
if !request.constraints.is_empty() {
@@ -564,7 +598,7 @@ pub fn build_optimization_prompt(
prompt.push_str("持仓数量属于策略合同,不是优化自由参数。原策略或用户目标明确 stocknum、selection.limit、目标持仓N只或不少于N只时,优化后必须保留该目标槽位或满足最低槽位,不能为了收益或交易次数擅自改小。\n");
prompt.push_str(DEFAULT_THREE_YEAR_RETURN_TARGET_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;不要回退 ficlaw-data、QuantAPI、旧数据中心 HTTP、ClickHouse 或临时文件。如上一轮无交易或质量分过低,必须先扩大候选覆盖并修正不可交易过滤,再优化收益。\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");
prompt.push_str(&format!("- {}\n\n", request.objective));
prompt.push_str("当前策略代码如下,仅作为输入参考;回复时不要包含 Markdown 代码围栏:\n");
@@ -602,10 +636,11 @@ mod tests {
market: "CN_A".to_string(),
benchmark_symbol: "000852.SH".to_string(),
signal_symbol: "000001.SH".to_string(),
holding_count_contract: None,
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 视为满足收益目标"));
assert!(prompt.contains("三年回测区间策略总收益严格 > 120% 视为满足收益目标"));
assert!(prompt.contains("不得把已达标策略判为失败"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册 source rows 字段"));
assert!(prompt.contains("不要回退 ficlaw-data"));
@@ -613,6 +648,16 @@ mod tests {
assert!(prompt.contains("T 日只生成订单意图"));
assert!(prompt.contains("按实际成交日判断"));
assert!(prompt.contains("禁止用 T 日执行状态拦截 T+1 可交易订单"));
assert!(prompt.contains("必须覆盖完整默认配置面"));
assert!(prompt.contains("reject_inactive_buy=true"));
assert!(prompt.contains("reject_inactive_sell=true"));
assert!(prompt.contains("reject_new_listing_buy=true"));
assert!(prompt.contains("reject_kcb_buy=true"));
assert!(prompt.contains("reject_bjse_buy=true"));
assert!(prompt.contains("reject_one_yuan_buy=true"));
assert!(prompt.contains("respect_allow_buy_sell=true"));
assert!(prompt.contains("stamp_tax_rate_before_change=0.001"));
assert!(prompt.contains("stamp_tax_change_date=\"2023-08-28\""));
}
#[test]
@@ -624,10 +669,11 @@ mod tests {
objective: "优化收益".to_string(),
result_summary: json!({ "total_return": 1.49 }),
diagnostics: Vec::new(),
holding_count_contract: None,
},
);
assert!(prompt.contains("三年回测区间策略总收益 >= 150% 视为满足收益目标"));
assert!(prompt.contains("三年回测区间策略总收益严格 > 120% 视为满足收益目标"));
assert!(prompt.contains("继续优化夏普、回撤、换手和稳定性"));
assert!(prompt.contains("Strategy Factory Source Lake 已注册并完成 PIT/as-of 审计"));
assert!(prompt.contains("不要回退 ficlaw-data"));
+79 -17
View File
@@ -55,15 +55,11 @@ pub struct SelectionContext<'a> {
impl SelectionContext<'_> {
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
let eligible = if self.defer_selection_risk {
self.data.fundamental_universe_on(self.decision_date)
} else {
match self.risk_config {
Some(risk_config) => self
.data
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
None => self.data.eligible_universe_on(self.decision_date).to_vec(),
}
let eligible = match (self.risk_config, self.defer_selection_risk) {
(Some(risk_config), false) => self
.data
.eligible_universe_on_with_risk_config(self.decision_date, risk_config),
_ => self.data.eligible_universe_on(self.decision_date).to_vec(),
};
match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible
@@ -75,9 +71,6 @@ impl SelectionContext<'_> {
}
fn selection_risk_decisions(&self) -> Vec<FidcRiskDecisionAudit> {
if self.defer_selection_risk {
return Vec::new();
}
let default_risk_config;
let risk_config = match self.risk_config {
Some(value) => value,
@@ -404,13 +397,16 @@ mod tests {
)
.unwrap();
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_st_selection = true;
risk_config.static_rules.reject_kcb_selection = true;
let (_selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
decision_date: d(),
benchmark: &benchmark(),
reference_level: 2000.0,
data: &data,
dynamic_universe: None,
risk_config: Some(&FidcRiskControlConfig::default()),
risk_config: Some(&risk_config),
defer_selection_risk: false,
});
@@ -433,7 +429,7 @@ mod tests {
}
#[test]
fn selector_defers_static_execution_risk_for_lagged_execution() {
fn selector_applies_configured_selection_risk_on_decision_date() {
let data = DataSet::from_components(
vec![
instrument("000001.SZ"),
@@ -459,13 +455,74 @@ mod tests {
)
.unwrap();
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_st_selection = true;
risk_config.static_rules.reject_kcb_selection = true;
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
decision_date: d(),
benchmark: &benchmark(),
reference_level: 2000.0,
data: &data,
dynamic_universe: None,
risk_config: Some(&FidcRiskControlConfig::default()),
risk_config: Some(&risk_config),
defer_selection_risk: false,
});
let selected_symbols = selected
.iter()
.map(|candidate| candidate.symbol.as_str())
.collect::<BTreeSet<_>>();
assert!(!selected_symbols.contains("000001.SZ"));
assert!(!selected_symbols.contains("688001.SH"));
assert!(selected_symbols.contains("000002.SZ"));
assert_eq!(diagnostics.not_eligible_count, 2);
let rules = diagnostics
.risk_decisions
.iter()
.map(|decision| decision.rule_code.as_str())
.collect::<BTreeSet<_>>();
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
}
#[test]
fn selector_can_defer_configured_selection_risk_without_losing_diagnostics() {
let data = DataSet::from_components(
vec![
instrument("000001.SZ"),
instrument("688001.SH"),
instrument("000002.SZ"),
],
vec![
market("000001.SZ", 10.0),
market("688001.SH", 10.0),
market("000002.SZ", 10.0),
],
vec![
factor("000001.SZ", 8.0),
factor("688001.SH", 9.0),
factor("000002.SZ", 10.0),
],
vec![
candidate("000001.SZ", true, false),
candidate("688001.SH", false, true),
candidate("000002.SZ", false, false),
],
vec![benchmark()],
)
.unwrap();
let selector = DynamicMarketCapBandSelector::new(2000.0, 7.0, 10.0, 0.0, 10, 0.0, 0.0, 0.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_st_selection = true;
risk_config.static_rules.reject_kcb_selection = true;
let (selected, diagnostics) = selector.select_with_diagnostics(&SelectionContext {
decision_date: d(),
benchmark: &benchmark(),
reference_level: 2000.0,
data: &data,
dynamic_universe: None,
risk_config: Some(&risk_config),
defer_selection_risk: true,
});
@@ -476,7 +533,12 @@ mod tests {
assert!(selected_symbols.contains("000001.SZ"));
assert!(selected_symbols.contains("688001.SH"));
assert!(selected_symbols.contains("000002.SZ"));
assert_eq!(diagnostics.not_eligible_count, 0);
assert!(diagnostics.risk_decisions.is_empty());
let rules = diagnostics
.risk_decisions
.iter()
.map(|decision| decision.rule_code.as_str())
.collect::<BTreeSet<_>>();
assert!(rules.contains("st"), "{:?}", diagnostics.risk_decisions);
assert!(rules.contains("kcb"), "{:?}", diagnostics.risk_decisions);
}
}
+197 -24
View File
@@ -265,7 +265,7 @@ fn broker_executes_explicit_order_value_buy() {
ask1: 10.01,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
}],
@@ -551,7 +551,7 @@ fn broker_executes_order_shares_and_order_lots() {
ask1: 10.01,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
}],
@@ -985,7 +985,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
bid1: 9.99,
ask1_volume: 1,
bid1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -998,7 +998,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
bid1: 10.19,
ask1_volume: 1,
bid1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -1784,7 +1784,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
ask1: 10.01,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
}],
@@ -1937,7 +1937,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
}
#[test]
fn broker_executes_intraday_last_on_start_quote_without_trade_delta() {
fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
@@ -2013,7 +2013,7 @@ fn broker_executes_intraday_last_on_start_quote_without_trade_delta() {
ask1: 15.21,
bid1_volume: 8,
ask1_volume: 8,
volume_delta: 0,
volume_delta: 800,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
}],
@@ -2131,7 +2131,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
ask1: 10.03,
bid1_volume: 2,
ask1_volume: 2,
volume_delta: 1,
volume_delta: 800,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
}],
@@ -2368,7 +2368,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
ask1: 10.02,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2381,7 +2381,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
ask1: 10.04,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2531,7 +2531,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
ask1: 10.02,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2544,7 +2544,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
ask1: 10.04,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2678,7 +2678,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
ask1: 9.99,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2691,7 +2691,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
ask1: 10.02,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2704,7 +2704,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
ask1: 10.04,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2717,7 +2717,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
ask1: 10.11,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2838,7 +2838,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
ask1: 12.01,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2851,7 +2851,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
ask1: 12.04,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2864,7 +2864,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
ask1: 12.07,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
@@ -2999,7 +2999,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
ask1: 10.02,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
}],
@@ -3116,7 +3116,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
ask1: 10.02,
bid1_volume: 1,
ask1_volume: 1,
volume_delta: 1,
volume_delta: 400,
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
}],
@@ -3349,7 +3349,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 +3515,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 +3531,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)
);
}
@@ -3725,6 +3725,179 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
);
}
#[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)
);
}
#[test]
fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
+17 -10
View File
@@ -21,16 +21,17 @@ runtime_hits="$(
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*test*.rs' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE' \
'fidatacenter|FIDATACENTER|/v1/backtest/data|/v1/xuntou|ClickHouse|clickhouse|CLICKHOUSE|FIDC_BT_INSTRUMENT_METADATA_CSV|FIDC_BT_WRITE_SNAPSHOTS|FIDC_BT_WRITE_CSV_SNAPSHOTS|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE|write_csv_snapshot_files|write_csv\(|FIDC_RISK_RUNTIME_FILE|FIDC_RISK_RUNTIME_URL|FIDC_FIRISK_RUNTIME_FILE|FiRisk runtime snapshot' \
crates Cargo.toml \
2>/dev/null || true
)"
if [[ -n "$runtime_hits" ]]; then
fail "legacy fidatacenter/ClickHouse runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
fail "legacy fidatacenter/ClickHouse/CSV snapshot/FiRisk runtime data source references are not allowed in fidc-backtest-engine" "$runtime_hits"
fi
manifest_hits="$(
@@ -48,10 +49,11 @@ local_only_hits="$(
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*test*.rs' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local' \
'FICLAW_DATA_AGENT_URL|ficlaw_data\.source_rows_v1|strategy-factory-source-lake://local|FIDC_BT_WRITE_COMBINED_SOURCE_ROW_CACHE' \
crates Cargo.toml \
2>/dev/null || true
)"
@@ -67,7 +69,8 @@ json_query_hits="$(
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*test*.rs' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'/v1/query/(source-rows|daily-execution-prices|minute-execution-prices|instruments|corporate-actions)\.json' \
@@ -86,7 +89,8 @@ fused_hits="$(
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*test*.rs' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'exported_fused|fidc_fused|fusion|fused|wide_table|wide table|source_rows_export|exported_daily|merged_daily|daily_merged|merged_source|materialized[_ -]source|materialized_source_rows|source_rows_materialized|融合表|融合宽表' \
@@ -105,7 +109,8 @@ feature_store_hits="$(
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*test*.rs' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'research_feature_store|feature_store|FEATURE_STORE|daily_minute_current|FIDC_STRATEGY_FACTORY_ENABLE_FEATURE_STORE_CACHE|ALPHA_FACTORY_ENABLE_FEATURE_STORE_CACHE|ENABLE_FEATURE_STORE_CACHE' \
@@ -124,7 +129,8 @@ truth_csv_hits="$(
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*test*.rs' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!crates/fidc-core/src/strategy_ai.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'FIDC_BT_TRUTH_STOCK_LIST_CSV|OMNI_BT_TRUTH_STOCK_LIST_CSV|OMNI_BACKTEST_TRUTH_STOCK_LIST_CSV|selection_source=truth_csv|truth_stock_list|truth_csv' \
@@ -143,7 +149,8 @@ csv_snapshot_loader_hits="$(
--glob '!**/docs/**' \
--glob '!**/*.md' \
--glob '!**/tests/**' \
--glob '!**/*test*.rs' \
--glob '!**/*.test.rs' \
--glob '!**/*_test.rs' \
--glob '!scripts/verify-no-legacy-data-source.sh' \
'from_csv_dir|from_partitioned_dir|instruments\.csv|candidate_flags\.csv|market\.csv|benchmark\.csv' \
crates Cargo.toml \
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PYTHON_BIN="${PYTHON_BIN:-python3}"
fail() {
local message="$1"
local details="${2:-}"
printf '[FAIL] %s\n' "$message" >&2
if [[ -n "$details" ]]; then
printf '%s\n' "$details" >&2
fi
exit 1
}
run_core_test() {
local filter="$1"
local tmp
tmp="$(mktemp)"
printf '[INFO] cargo test -p fidc-core %s\n' "$filter"
if cargo test -p fidc-core "$filter" -- --nocapture 2>&1 | tee "$tmp"; then
local passed_count
passed_count="$(
"$PYTHON_BIN" - "$tmp" <<'PY'
import re
import sys
count = 0
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
match = re.search(r"test result: ok\. (\d+) passed;", line)
if match:
count += int(match.group(1))
print(count)
PY
)"
rm -f "$tmp"
if [[ "$passed_count" -le 0 ]]; then
fail "cargo test filter matched 0 tests: package=fidc-core filter=${filter}"
fi
return 0
fi
local output
output="$(cat "$tmp")"
rm -f "$tmp"
fail "cargo test failed: package=fidc-core filter=${filter}" "$output"
}
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 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
run_core_test next_open_sell_limit_risk_uses_open_not_close
run_core_test next_bar_open_sell_respects_allow_sell_policy_on_execution_day
run_core_test volume_limit_uses_floor_for_odd_lot_sell
run_core_test configurable_upper_limit_buy_filter_can_be_disabled
printf '[OK] fidc-backtest-engine runtime risk contracts passed\n'