对,这个结果很正常,而且说明一件事: 你现在的 v2.0 还处在“识别更强,但分配还没跟上”的阶段。 你已经把: 行业扩散度 主线识别 加进去了,但还没把: 单行业上限 单票上限 过热降权 接进组合构建层。 所以现在会出现一个典型现象: 识别更聪明了,但持仓分配还是老方法。 结果就是主线识别带来的收益没有完全兑现,反而可能把波动先放大。 你现在的对比很能说明这个问题: v1.9:113.36%,19.26% v2.0:99.42%,20.51% 这通常不是“方向错了”,而是: 主线识别模块已经改变了选股排序,但仓位分配还没有和新排序逻辑配套。 也就是说,v2.0 目前像是“半套系统”。 一、我先给判断 现在不要回退 不要因为 v2.0 暂时不如 v1.9,就怀疑行业扩散度和主线识别本身。 更可能的原因是这三个: 1)主线识别把股票排序改了,但组合分配还按旧逻辑 如果仍然接近等权,主线识别的价值很难完整释放。 2)主线识别提高了组合风格暴露,但没有行业上限约束 结果可能是: 该集中的地方不够集中 不该过度集中的地方又没有刹车 3)行业过热后的降温机制没加 主线识别容易把你带到更强的行业里,但如果没有“过热降权”,就容易在主线后半段吃回撤。 所以你现在最该做的,不是继续微调行业打分参数,而是把风险预算层补上。 二、风险预算不要做得太复杂 你说得对,完整的风险预算实现会比较复杂。 但你现在没必要一步做到机构级优化器。 先做一个“轻量版风险预算”就够了。 最实用的是分三步: 第一步:先做“后处理约束” 不要在选股阶段就硬限制行业数量。 先按分数选出候选池,再做一个“权重裁剪”。 这是最容易接进现有代码的。 第二步:先限制“权重”,不要先限制“仓位个数” 比如: 单行业不超过 40% / 50% 单票不超过 15% / 18% 这比“行业最多几只票”更自然,也更稳定。 第三步:过热降权做成惩罚项,不要做硬剔除 不要: 行业过热就直接不买 要改成: 行业过热就降低权重上限 或降低行业 bonus 这样不容易错杀强趋势。 三、最容易落地的实现方案 我建议你先做一个 v2.0a 轻量风险预算版,只加下面三件事: 1)单票上限 按大势状态给不同上限: MAX_STOCK_WEIGHT = { 'RISK_SEVERE': 0.08, 'RISK_MILD': 0.10, 'RANGE': 0.12, 'TREND_NORMAL': 0.15, 'TREND_BULL': 0.18 } 2)单行业上限 MAX_INDUSTRY_WEIGHT = { 'RISK_SEVERE': 0.20, 'RISK_MILD': 0.25, 'RANGE': 0.30, 'TREND_NORMAL': 0.40, 'TREND_BULL': 0.50 } 3)行业过热降权 不是剔除,而是把行业上限打折: if ret20 > 25 and accel5 < 0: industry_cap *= 0.7 elif ret20 > 18 and accel5 < 0: industry_cap *= 0.85 这三件事已经足够把 v2.0 补成完整闭环。 四、不要在“选股时限制行业最多多少仓位” 这一条我建议你改一下思路。 你说的实现方向是: 选股时限制单行业最多多少仓位 选股时限制单票最多多少仓位 其中第 1 条如果放在“选股阶段”做,代码会很绕,而且容易误杀好票。 更好的方法是: 先排序选股,再做权重分配时约束。 原因很简单: 排序阶段负责“谁更强” 权重阶段负责“给多少” 这两个层次分开,代码更清晰,也更不容易出 bug。 五、我建议你的代码结构这样改 你现在大概率已经有: factor_select_v20(...) run_backtest(...) 那就新增一个函数: 新函数:build_target_weights_v20() 这个函数只做一件事: 给已选中的股票,分配满足风险约束的目标权重。 你完全不需要重写整个回测框架,只要把原来“等权/简单加权”的地方替换掉。 六、直接给你可接入的主要代码 1)先加参数 MAX_STOCK_WEIGHT = { 'RISK_SEVERE': 0.08, 'RISK_MILD': 0.10, 'RANGE': 0.12, 'TREND_NORMAL': 0.15, 'TREND_BULL': 0.18 } MAX_INDUSTRY_WEIGHT = { 'RISK_SEVERE': 0.20, 'RISK_MILD': 0.25, 'RANGE': 0.30, 'TREND_NORMAL': 0.40, 'TREND_BULL': 0.50 } 2)行业过热判断 你已经有行业信息了,补一个简单函数: def calc_industry_overheat(ind_info): """ ind_info 里至少包含: - ret20 - accel5 """ if ind_info is None: return 0.0 ret20 = ind_info.get('ret20', 0.0) accel5 = ind_info.get('accel5', 0.0) if ret20 > 25 and accel5 < 0: return 0.30 # 很热,行业上限打7折 elif ret20 > 18 and accel5 < 0: return 0.15 # 偏热,行业上限打85折 return 0.0 3)核心:风险预算权重分配函数 这是你现在最该加的。 def build_target_weights_v20(selected, meta, regime, industry_rank_map): """ selected: [(code, price, score), ...] meta: { code: { 'industry_code': xxx, 'is_mainline': True/False } } industry_rank_map: { industry_code: { 'rank': ..., 'ret20': ..., 'accel5': ... } } 返回: {code: weight} """ if len(selected) == 0: return {} max_stock_w = MAX_STOCK_WEIGHT.get(regime, 0.12) base_industry_cap = MAX_INDUSTRY_WEIGHT.get(regime, 0.30) # 1) 先给基础权重:按排序递减,而不是等权 if regime == 'TREND_BULL': raw_base = [0.20, 0.18, 0.16, 0.14, 0.12, 0.10, 0.10] elif regime == 'TREND_NORMAL': raw_base = [0.16, 0.15, 0.14, 0.13, 0.12, 0.10, 0.10, 0.10] else: raw_base = [1.0 / len(selected)] * len(selected) raw_base = raw_base[:len(selected)] if len(raw_base) < len(selected): raw_base.extend([raw_base[-1]] * (len(selected) - len(raw_base))) s = sum(raw_base) base_weights = [x / s for x in raw_base] # 2) 为每个行业计算行业上限(考虑过热降权) industry_caps = {} for code, _, _ in selected: ind = meta.get(code, {}).get('industry_code', 'UNKNOWN') if ind not in industry_caps: ind_info = industry_rank_map.get(ind, None) overheat = calc_industry_overheat(ind_info) cap = base_industry_cap * (1.0 - overheat) industry_caps[ind] = max(0.10, cap) # 防止上限太低 # 3) 第一轮分配:受单票/单行业上限约束 weights = {} industry_alloc = {} for i, (code, _, _) in enumerate(selected): ind = meta.get(code, {}).get('industry_code', 'UNKNOWN') desired = base_weights[i] stock_cap = max_stock_w industry_cap = industry_caps.get(ind, base_industry_cap) used_industry = industry_alloc.get(ind, 0.0) industry_room = max(0.0, industry_cap - used_industry) w = min(desired, stock_cap, industry_room) weights[code] = w industry_alloc[ind] = used_industry + w # 4) 第二轮:把剩余权重继续分给还有空间的股票 remain = 1.0 - sum(weights.values()) if remain > 1e-8: for i, (code, _, _) in enumerate(selected): ind = meta.get(code, {}).get('industry_code', 'UNKNOWN') stock_cap = max_stock_w industry_cap = industry_caps.get(ind, base_industry_cap) stock_room = max(0.0, stock_cap - weights[code]) industry_room = max(0.0, industry_cap - industry_alloc.get(ind, 0.0)) addable = min(stock_room, industry_room, remain) if addable > 0: weights[code] += addable industry_alloc[ind] = industry_alloc.get(ind, 0.0) + addable remain -= addable if remain <= 1e-8: break # 5) 如果还有剩余,最后按已有权重归一化 total_w = sum(weights.values()) if total_w > 0: for code in weights: weights[code] /= total_w return weights 4)在 run_backtest 里替换目标持仓分配 你原来大概率是这种: target_stock_value = total_value * target / len(selected) target_per_stock = {s: target_stock_value for s, _, _ in selected} 改成: selected, rank_map, meta, industry_rank_map = factor_select_v20( data=data, date=date, regime=final_state, n=n, current_positions=positions ) target_weight_map = build_target_weights_v20( selected=selected, meta=meta, regime=final_state, industry_rank_map=industry_rank_map ) target_per_stock = {} for s, _, _ in selected: target_per_stock[s] = total_value * target * target_weight_map.get(s, 0.0) 5)把 factor_select_v20() 返回值多带一个 industry_rank_map 你前面主线识别已经搭了,那这里顺手返回: return selected, rank_map, meta, industry_rank_map 这样 run_backtest 就能直接拿来做过热降权,不用重复计算。 七、为什么这套实现最适合你现在 因为它有 4 个优点: 1)改动不算大 你不用重写选股逻辑,只是在“已选中股票之后”增加一个权重裁剪层。 2)职责清晰 选股函数负责排序 风险预算函数负责分配 回测函数负责执行 以后调试很方便。 3)不会把主线识别白做 现在 v2.0 收益没出来,很可能就是因为你识别了主线,但没有把权重真正给到位。 这个函数就是补这一步。 4)比“选股阶段硬限制行业票数”更稳 硬限制很容易把真正强的主线股砍掉。 权重约束更柔和,通常回测表现也更稳定。 八、我对你下一版的预期 如果你把这层接进去,理论上会看到这样的变化: 收益不一定立刻超过 v1.9 但 v2.0 的逻辑会完整起来 回撤应该比“无约束主线集中”更稳 2024/2025 主线年份会更像“有组织地集中收益” 2022 这类年份更不容易被单行业拖伤 你现在最重要的不是马上追求超过 113.36%,而是先让 v2.0 形成真正闭环: 主线识别 → 个股排序 → 风险预算 → 执行交易 现在你只差最后一环。 九、我建议你的测试顺序 先别一次上太多变量,按这个顺序测: 第一步 只加: MAX_STOCK_WEIGHT MAX_INDUSTRY_WEIGHT 先不加过热降权。 第二步 确认结构稳定后,再加: calc_industry_overheat() 第三步 最后再调: TREND_BULL 下的基础权重序列 这样你能知道到底是哪一步带来的收益/回撤变化。 你现在方向是对的,v2.0 没有失败,只是还没闭环。