好的,我直接给你 方案1:v1.5 回测代码骨架(在你 backtest_final.py 的基础上改),目标是让“行业轮动”从 只改排序 → 升级为 行业预算约束(真正影响买入金额)。 下面代码你复制进现有脚本里,按我标注的“替换/新增”位置改就能跑。 1)新增:行业预算生成(Industry Budget) 把这段加到你的脚本里(建议放在 IndustryV11/IndustryV12 类下面): import math from collections import defaultdict def make_industry_budget(ind_weights: dict, stock_to_industry: dict, available_stocks: list, method: str = "clip_norm", min_w: float = 0.05, max_w: float = 0.35) -> dict: """ 将行业轮动模块输出的 ind_weights(0.7~1.35) 转成“行业预算”(总和=1)。 - 只对你股票池里实际存在的行业分配预算 - 默认 clip_norm:先裁剪,再按比例归一 """ inds_in_pool = set() for s in available_stocks: ind = stock_to_industry.get(s) if ind: inds_in_pool.add(ind) if not inds_in_pool: return {} # 取出行业强度 raw = {} for ind in inds_in_pool: raw[ind] = float(ind_weights.get(ind, 1.0)) if ind_weights else 1.0 if method == "softmax": # softmax(更“激进”),温度可自行加参数 exps = {k: math.exp(v) for k, v in raw.items()} s = sum(exps.values()) bud = {k: (exps[k] / s if s > 0 else 1.0 / len(exps)) for k in exps} else: # clip_norm(更稳健) clipped = {k: max(0.7, min(1.35, v)) for k, v in raw.items()} s = sum(clipped.values()) bud = {k: (clipped[k] / s if s > 0 else 1.0 / len(clipped)) for k in clipped} # 加预算上下限(防止行业预算极端) # 先clip bud = {k: max(min_w, min(max_w, v)) for k, v in bud.items()} # 再归一化 s = sum(bud.values()) if s <= 0: return {k: 1.0 / len(bud) for k in bud} bud = {k: v / s for k, v in bud.items()} return bud 2)新增:按行业预算选股(行业内选股 + 行业间分配名额) 新增下面这个函数(放在你 select_stocks() 下面): def select_stocks_by_industry_budget(stocks_df, date, dates, core_stocks, total_n, industry_budget, stock_to_industry, momentum_period=20): """ v1.5:按行业预算分配名额,然后在每个行业内按个股动量选股 - total_n:总持仓只数(原来按state确定) - industry_budget:行业预算(总和=1) """ # 1) 过滤可交易股票 avail = [s for s in core_stocks if s in stocks_df.columns and date in stocks_df.index and not pd.isna(stocks_df.loc[date, s]) and stocks_df.loc[date, s] > 0] if not avail or total_n <= 0: return [] # 2) 计算个股动量 idx = list(dates).index(date) old_date = dates[idx - momentum_period] if idx >= momentum_period else None stock_mom = {} for s in avail: try: p_now = float(stocks_df.loc[date, s]) if not old_date or old_date not in stocks_df.index: mom = 0.0 else: p_old = float(stocks_df.loc[old_date, s]) mom = (p_now / p_old - 1) * 100 if p_old > 0 else 0.0 stock_mom[s] = mom except: stock_mom[s] = 0.0 # 3) 分组到行业 ind_to_stocks = defaultdict(list) for s in avail: ind = stock_to_industry.get(s, None) if ind is None: ind = "UNKNOWN" ind_to_stocks[ind].append(s) # 如果没有行业预算,退化为全体动量排序 if not industry_budget: ranked = sorted([(s, stock_mom[s]) for s in avail], key=lambda x: x[1], reverse=True) return [(s, stock_mom[s], float(stocks_df.loc[date, s])) for s, _ in ranked[:total_n]] # 4) 按行业预算分配名额(整数) inds = list(ind_to_stocks.keys()) # 对不在budget里的行业给一个很小预算,避免丢失 bud = {ind: float(industry_budget.get(ind, 0.0)) for ind in inds} bud_sum = sum(bud.values()) if bud_sum <= 0: bud = {ind: 1.0 / len(inds) for ind in inds} else: bud = {ind: bud[ind] / bud_sum for ind in inds} # 初步分配名额 alloc = {ind: int(round(bud[ind] * total_n)) for ind in inds} # 修正总数(保证合计 = total_n) diff = total_n - sum(alloc.values()) if diff != 0: # 用预算从大到小补/减 order = sorted(inds, key=lambda x: bud[x], reverse=True) step = 1 if diff > 0 else -1 for _ in range(abs(diff)): for ind in order: if step > 0: alloc[ind] += 1 break else: if alloc[ind] > 0: alloc[ind] -= 1 break # 5) 行业内按动量挑 alloc[ind] 只 selected = [] for ind, cnt in alloc.items(): if cnt <= 0: continue group = ind_to_stocks[ind] ranked = sorted(group, key=lambda s: stock_mom[s], reverse=True) pick = ranked[:min(cnt, len(ranked))] for s in pick: selected.append((s, stock_mom[s], float(stocks_df.loc[date, s]), ind)) # 若因行业股票不足导致少选,补足(全局动量补齐) if len(selected) < total_n: chosen_set = set([x[0] for x in selected]) remain = [s for s in avail if s not in chosen_set] ranked = sorted(remain, key=lambda s: stock_mom[s], reverse=True) for s in ranked[:(total_n - len(selected))]: ind = stock_to_industry.get(s, "UNKNOWN") selected.append((s, stock_mom[s], float(stocks_df.loc[date, s]), ind)) return selected[:total_n] 3)改造调仓:按行业预算分配买入金额(核心升级点) 你原来的买入是: per_stock_value = target_stock_value / len(selected) 这会导致“行业预算不起作用”。 现在改成: 行业先分资金 → 行业内再分到股票 在 run_backtest() 里,找到你“买入新持仓”那一段,把整个买入段替换成下面这个版本(注意:这段是替换,不是新增): # 买入新持仓(v1.5:行业预算约束) if selected and target_stock_value > 0: # 先把selected按行业分组 sel_by_ind = defaultdict(list) for s, mom, price, ind in selected: sel_by_ind[ind].append((s, mom, price)) # 行业预算(只针对本次选中的行业) inds = list(sel_by_ind.keys()) bud = {} if industry_budget: for ind in inds: bud[ind] = float(industry_budget.get(ind, 0.0)) else: bud = {ind: 1.0 / len(inds) for ind in inds} s_bud = sum(bud.values()) if s_bud <= 0: bud = {ind: 1.0 / len(inds) for ind in inds} else: bud = {ind: bud[ind] / s_bud for ind in inds} # 行业资金 → 行业内等权(你也可以改成按动量加权) for ind, stocks_in_ind in sel_by_ind.items(): ind_value = target_stock_value * bud[ind] if ind_value <= 0: continue per_stock_value = ind_value / len(stocks_in_ind) for s, mom, p in stocks_in_ind: if s in positions: continue shares = int(per_stock_value / p) if shares <= 0: continue cost = shares * p if cost <= cash: # ✅ 先别透支,避免回撤畸形 cash -= cost positions[s] = shares trades.append({ 'date': str(date.date()), 'stock': s, 'action': 'BUY', 'state': state }) 我这里把你原来的 允许小幅透支 cash * 1.1 去掉了,原因很简单: 透支会在回测里人为拉高回撤/交易噪声,特别是行业轮动更频繁时。 如果你确实想保留透支,建议做成“手续费+滑点+融资成本”一起加进去才公平。 4)在回测主循环里接上 v1.5 的“行业预算 + 行业内选股” 在 run_backtest() 里,调仓处你现在是: selected = select_stocks(...) 改成下面(替换那一段选股逻辑): n = TOP_N.get(state, 4) # 行业权重(来自 v1.1 / v1.2) ind_weights = {} if industry_module and date in industry_df.index: ind_weights = industry_module.get_weights(industry_df, date) # ✅ v1.5:把行业权重变成“行业预算” industry_budget = make_industry_budget( ind_weights=ind_weights, stock_to_industry=STOCK_TO_INDUSTRY, available_stocks=core_stocks, method="clip_norm", # 你也可以试 softmax min_w=0.05, max_w=0.35 ) if target > 0 and len(core_stocks) > 0 and n > 0: selected = select_stocks_by_industry_budget( stocks_df=stocks_df, date=date, dates=dates, core_stocks=core_stocks, total_n=n, industry_budget=industry_budget, stock_to_industry=STOCK_TO_INDUSTRY, momentum_period=MOMENTUM_PERIOD ) else: selected = [] 5)你需要注意的一个“对比公平性”建议 你现在对比无轮动 vs 轮动,轮动版会有更高换手。为了比较公平,建议下一步你加两项(我先给你接口位置): 手续费(千分之 1~2) 滑点(万分之几到千分之几) 等你 v1.5 跑通后我再帮你把手续费/滑点补上,否则你会看到轮动版在回测里“看起来更好/更差”,但其实是交易成本没算造成的偏差。 6)你跑完后,怎么判断“轮动是否真正生效”? 看 3 个现象(只要出现就说明生效了): 持仓行业分布明显随时间变化(不是永远那几个行业) 行业强势期收益提升(例如新能源/AI/券商轮动周期) 组合回撤结构改变(回撤峰值出现的日期可能不同) 如果你愿意,把你跑出来的对比结果截图(或把输出表发我),我可以继续帮你做第二步: 归因诊断(轮动贡献来自收益还是回撤、是否只在牛市有效、v1.1 vs v1.2 哪个更适合你这套大势层)。