#!/usr/bin/env python3 """MoFin 策略实验室 v2 — 多版本策略回测 + 12维上下文 + 因子归因 维度: 个股技术(水平+趋势变化) / 大盘状态 / 行业强度 每个策略版本 = 命名配置 + 元数据(名称/假设/父版本)""" import sqlite3, json, math, os from datetime import datetime, timedelta DB_PATH = "/home/hmo/MoFin/data/mofin.db" import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from backtest_framework import prepare_bars, compute_single_score, compute_kelly # bars 缓存:批量跑多版本时共享 TA 计算 _BARS_CACHE = {} def _bars(code, start_date, end_date): key = (code, start_date, end_date) if key not in _BARS_CACHE: _BARS_CACHE[key] = prepare_bars(code, start_date, end_date) return _BARS_CACHE[key] # ══════════════════════════════════════════════════════ # 策略版本注册表 # ══════════════════════════════════════════════════════ STRATEGIES = { "v1.0": { "version": "v1.0", "name": "多因子基线", "summary": "五因子评分≥45 + 动量≥8,10%止盈 / 2×ATR止损,半Kelly", "hypothesis": "基线版本:验证多因子评分体系的基础有效性", "parent": None, "created": "2026-07-28", "config": { "entry": {"min_score": 45, "min_momentum": 8, "filters": {}}, "exit": {"tp_pct": 0.10, "sl_atr": 2.0, "max_hold_days": 20}, "sizing": {"kelly": True, "kelly_fraction": 0.5}, "eval_step": 5, }, }, "v2.0": { "version": "v2.0", "name": "趋势动能过滤", "summary": "v1 + MACD柱>0 + ROC>2 + ADX≥20 + ATR%≥2.8 过滤弱势入场", "hypothesis": "v1归因:MACD>0.69胜率54%vs33%,ROC>11胜率57%vs35%,ADX>43胜率50%,ATR%>4.3胜率49%vs31%。过滤无趋势/无动能/死鱼股", "parent": "v1.0", "created": "2026-07-28", "config": { "entry": {"min_score": 45, "min_momentum": 8, "filters": {"adx_min": 20, "atr_pct_min": 2.8, "roc_min": 2, "macd_hist_min": 0}}, "exit": {"tp_pct": 0.10, "sl_atr": 2.0, "max_hold_days": 20}, "sizing": {"kelly": True, "kelly_fraction": 0.5}, "eval_step": 5, }, }, "v3.0": { "version": "v3.0", "name": "强动量+优盈亏比", "summary": "v2 + ROC≥8 + 距MA20≥4% + 量比1.0~1.8;止盈15%/止损1.5×ATR(RR→2.2:1)", "hypothesis": "v2归因:ROC>17.5胜率58.5%,距MA20>12.9胜率56.5%,量比1.12~1.45胜率55.1%。且v2平均亏损-9.14%≈止盈10%,RR仅1.1:1是盈亏比恶化主因→收紧止损放大止盈", "parent": "v2.0", "created": "2026-07-28", "config": { "entry": {"min_score": 45, "min_momentum": 8, "filters": {"adx_min": 20, "atr_pct_min": 2.8, "roc_min": 8, "macd_hist_min": 0, "dist_ma20_min": 4, "vol_ratio_min": 1.0, "vol_ratio_max": 1.8}}, "exit": {"tp_pct": 0.15, "sl_atr": 1.5, "max_hold_days": 20}, "sizing": {"kelly": True, "kelly_fraction": 0.5}, "eval_step": 5, }, }, "v4.0": { "version": "v4.0", "name": "大盘回调+趋势结构", "summary": "v3 + 大盘须在MA20上且MA20斜率<-0.05(上升中回调) + 个股更高高点结构 + ROC 10~25 + MACD柱<1.3(避追高)", "hypothesis": "v3归因:大盘MA20斜率-1.76~-0.56时胜率58.8%vs平坡20.6%(差38pp最强信号);大盘在MA20上胜率42%vs34%;hh结构+15pp;ROC甜区12.9~16.2胜率61%;MACD柱>1.33胜率仅28%(追高必死);个股MA20斜率<1.5胜率56%vs≥1.5约35%(强势回调买)", "parent": "v3.0", "created": "2026-07-28", "config": { "entry": {"min_score": 45, "min_momentum": 8, "filters": {"adx_min": 20, "atr_pct_min": 3.5, "atr_pct_max": 5.5, "roc_min": 10, "roc_max": 25, "macd_hist_min": 0.25, "macd_hist_max": 1.3, "dist_ma20_min": 4, "vol_ratio_min": 1.2, "vol_ratio_max": 1.5, "ma20_slope_max": 1.5, "mkt_above_ma20": True, "mkt_slope_max": -0.05, "mkt_adx_min": 20, "hh_only": True}}, "exit": {"tp_pct": 0.15, "sl_atr": 1.5, "max_hold_days": 20}, "sizing": {"kelly": True, "kelly_fraction": 0.5}, "eval_step": 1, }, }, "v4.1": { "version": "v4.1", "name": "大盘回调·宽松带", "summary": "v4.0放宽:ROC 8~25 + MACD柱0~1.5 + ATR 2.8~6.0 + 量比1.0~1.8;保留大盘MA20上+斜率<0 + hh结构", "hypothesis": "v4.0仅5笔交易=过滤器叠加过拟合(分桶样本仅36笔/桶)。保留归因最强的市场状态+趋势结构信号,放宽窄幅过滤器换取统计样本量", "parent": "v4.0", "created": "2026-07-28", "config": { "entry": {"min_score": 45, "min_momentum": 8, "filters": {"adx_min": 20, "atr_pct_min": 2.8, "atr_pct_max": 6.0, "roc_min": 8, "roc_max": 25, "macd_hist_min": 0, "macd_hist_max": 1.5, "dist_ma20_min": 4, "vol_ratio_min": 1.0, "vol_ratio_max": 1.8, "ma20_slope_max": 1.5, "mkt_above_ma20": True, "mkt_slope_max": 0, "hh_only": True}}, "exit": {"tp_pct": 0.15, "sl_atr": 1.5, "max_hold_days": 20}, "sizing": {"kelly": True, "kelly_fraction": 0.5}, "eval_step": 5, }, }, } # ══════════════════════════════════════════════════════ # v4.0 分支家族:消融实验(每次只动一个维度) # ══════════════════════════════════════════════════════ _V40_BASE = STRATEGIES["v4.0"]["config"] def _v40_branch(version, name, summary, hypothesis, entry_overrides=None, exit_overrides=None): import copy cfg = copy.deepcopy(_V40_BASE) for k, v in (entry_overrides or {}).items(): if k in ("min_score", "min_momentum"): cfg["entry"][k] = v else: cfg["entry"]["filters"][k] = v for k, v in (exit_overrides or {}).items(): cfg["exit"][k] = v return { "version": version, "name": name, "summary": summary, "hypothesis": hypothesis, "parent": "v4.0", "created": "2026-07-28", "config": cfg, } STRATEGIES.update({ # A组: 出场优化(严格入场不变) "v4.0a": _v40_branch("v4.0a", "移动止盈路径", "v4.0入场不变;出场改移动止盈(跟踪1.5×ATR),无固定目标,让利润奔跑", "v4.0五笔4赢且均赢+13%,固定15%目标可能截断大趋势;跟踪止损可锁定利润同时保留上行空间", exit_overrides={"tp_pct": None, "trail_atr": 1.5, "sl_atr": 1.5, "max_hold_days": 25}), "v4.0b": _v40_branch("v4.0b", "延长持仓路径", "v4.0入场不变;持仓期20→25天,给趋势更多兑现时间", "v3归因显示15天+持仓胜率45.6%为各档最高,强信号可能需要更长时间兑现", exit_overrides={"max_hold_days": 25}), # B组: 单维度放宽(消融,找瓶颈) "v4.0c": _v40_branch("v4.0c", "单放ROC", "v4.0只放宽ROC: 10~25 → 8~30,其余全保持", "消融实验:ROC带是否是交易数瓶颈?放宽后若胜率不降则ROC带可永久放宽", entry_overrides={"roc_min": 8, "roc_max": 30}), "v4.0d": _v40_branch("v4.0d", "单放ATR", "v4.0只放宽ATR%: 3.5~5.5 → 2.8~6.5,其余全保持", "消融实验:ATR带是否过窄排除了高波动赢家?", entry_overrides={"atr_pct_min": 2.8, "atr_pct_max": 6.5}), "v4.0e": _v40_branch("v4.0e", "单放MACD", "v4.0只放宽MACD柱: 0.25~1.3 → 0~2.0,其余全保持", "消融实验:MACD柱0~0.25区间(v3:36%胜率)和>1.3区间(28%)是否真该排除?", entry_overrides={"macd_hist_min": 0, "macd_hist_max": 2.0}), "v4.0f": _v40_branch("v4.0f", "单放量比", "v4.0只放宽量比: 1.2~1.5 → 0.9~2.0,其余全保持", "消融实验:量比带的贡献度几何?", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0}), "v4.0g": _v40_branch("v4.0g", "单放大盘斜率", "v4.0只放宽大盘MA20斜率: ≤-0.05 → ≤0.5,其余全保持", "消融实验:大盘斜率是最强信号(38pp)但也是最大限制——放到0.5还能保住边缘吗?", entry_overrides={"mkt_slope_max": 0.5}), # C组: 结构替代 "v4.0h": _v40_branch("v4.0h", "去高点结构", "v4.0去掉hh_only(更高高点结构)要求,其余全保持", "hh结构+15pp但样本仅4笔False组——这个过滤器可能既限数量又未必真实有效", entry_overrides={"hh_only": False}), # D组: 融合胜出路径 "v5.0": _v40_branch("v5.0", "量比+ATR融合", "v4.0 + 量比0.9~2.0 + ATR 2.8~6.5(消融胜出的双放宽融合)", "消融结果:单放量比+10笔保73%胜率/盈亏比2.92,单放ATR+4笔保78%胜率——两者是唯一不稀释质量的放宽,融合期望20+笔且保住70%胜率", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "atr_pct_min": 2.8, "atr_pct_max": 6.5}), # E组: 资金面/板块增强 "v6.0": _v40_branch("v6.0", "资金流过滤", "v4.0f + 资金5日净占比>-2.5(排除持续流出)", "资金流归因:flow_5d<-2.7的持续流出组胜率仅37.5%,其余各桶55-78%。排除主力持续出逃的标的;flow_delta>4虽77.8%但会过度砍样本暂不启用", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "flow_5d_min": -2.5}), "v6.1": _v40_branch("v6.1", "板块不追高", "v4.0f + 板块MA20斜率≤1.0(排除已强涨板块)", "板块归因:sector_slope>1.04的强涨板块入场胜率仅27.3%,而斜率≤0.08的回调/横盘板块胜率80%。与大盘/个股层面的'回调买'规律三层同构", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0}), "v6.2": _v40_branch("v6.2", "资金+板块双滤", "v4.0f + 资金5日净占比>-2.5 + 板块MA20斜率≤1.0", "资金流(37.5%→55-78%)与板块(27.3%→80%)两个独立维度的负向排除叠加,期望在v4.0f基础上再提升胜率且不显著减样本", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "flow_5d_min": -2.5, "sector_slope_max": 1.0}), # F组: 出场优化 + 尸检因子(基于 v6.1) "v7.0": _v40_branch("v7.0", "分批止盈", "v6.1入场不变;出场改50%@+8%落袋+50%@+15%,止损不变", "亏损尸检:12笔亏损9笔为止损出局——先到+8%落袋一半可将部分止损单转为盈利单;牺牲部分大赢换取胜率", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0}, exit_overrides={"tp_pct": None, "staged_tp": [[0.5, 0.08], [0.5, 0.15]], "sl_atr": 1.5, "max_hold_days": 20}), "v7.1": _v40_branch("v7.1", "尸检因子过滤", "v6.1 + 必须hl结构(更高低点) + RSI增量≥6(动量加速)", "亏损尸检:盈利组100%具备hl结构而亏损组仅75%;盈利组RSI增量11.3 vs 亏损组6.1——动量加速度区分输赢", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}), "v7.2": _v40_branch("v7.2", "尸检+分批", "v7.1入场 + 分批止盈出场(双管齐下)", "入场端尸检因子过滤+出场端分批止盈,两个独立改进点叠加", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"tp_pct": None, "staged_tp": [[0.5, 0.08], [0.5, 0.15]], "sl_atr": 1.5, "max_hold_days": 20}), "v7.3": _v40_branch("v7.3", "资金+板块+分批", "v6.2入场(资金+板块双滤) + 分批止盈出场", "v6.2的82.1%胜率入场叠加分批止盈,目标在不损胜率前提下改善盈亏结构", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "flow_5d_min": -2.5, "sector_slope_max": 1.0}, exit_overrides={"tp_pct": None, "staged_tp": [[0.5, 0.08], [0.5, 0.15]], "sl_atr": 1.5, "max_hold_days": 20}), # G组: 筹码/结构出场(趋势持有与波段) "v8.0": _v40_branch("v8.0", "趋势持有", "v7.1入场;出场改结构驱动:破MA10两日/破MA20/横盘出货识别,无固定目标,最长40天", "用户经验:拉伸段不必早出场,固定15%目标截断利润。让利润奔跑至结构破位或高位放量滞涨(出货)信号出现", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"tp_pct": None, "exit_mode": "structure", "sl_atr": 1.5, "max_hold_days": 40}), "v8.1": _v40_branch("v8.1", "波段先出再进", "v7.1入场;跌破MA10先出,10日内收回MA10且创新高再进,各段复合计算,最长60天", "用户经验:调整时先出再进可避开回撤段——破MA10锁定利润,结构恢复再进场吃下一波", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"tp_pct": None, "exit_mode": "swing", "sl_atr": 1.5, "max_hold_days": 60, "reentry_days": 10}), "v8.2": _v40_branch("v8.2", "趋势持有(宽入场)", "v6.1入场;出场同v8.0结构驱动——消融对比:结构出场本身贡献多少", "对照实验:v6.1固定15%目标 vs v8.2结构持有,同入场下隔离出场模式的贡献", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0}, exit_overrides={"tp_pct": None, "exit_mode": "structure", "sl_atr": 1.5, "max_hold_days": 40}), "v8.3": _v40_branch("v8.3", "波段40天", "v8.1持仓期60→40天,检验长尾巴交易的必要性", "v8.1平均持仓58天接近上限,若40天版收益率不降说明长尾可砍、资金周转更优", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"tp_pct": None, "exit_mode": "swing", "sl_atr": 1.5, "max_hold_days": 40, "reentry_days": 10}), # H组: 多周期维度 "v9.0": _v40_branch("v9.0", "周线趋势过滤", "v7.1 + 周线收盘须站上周线MA10(中期趋势向上才买)", "L2多周期维度消融:日线级的回调买点若周线趋势已坏则是下跌中继;周线MA10上方=中期趋势完好", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6, "weekly_up": True}), "v9.1": _v40_branch("v9.1", "周线多头排列", "v7.1 + 周线MA10>MA20(周线多头排列,更严的中期趋势要求)", "比weekly_up更严的变体:不仅要求价在线上,还要求周线均线本身多头排列", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6, "weekly_aligned": True}), "v9.2": _v40_branch("v9.2", "周线破位日线强", "v7.1 + 周线收盘须低于周线MA10(反向利用:周线回调+日线走强=最佳买点)", "v9归因反用:v7.1交易中weekly_up=False胜率78.4% vs True 60.9%——周线级回调中的日线动量回归正是本策略的核心边缘", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6, "weekly_down_only": True}), # I组: 枢轴S/R出场(与实盘technical_analysis同算法) "v7.1b": _v40_branch("v7.1b", "v7.1+动量基因缩放", "v7.1全执行 + 收缩突破动量基因票仓位×2.5——信念缩放(不过滤)", "v7.1的75笔中15笔带收缩突破DNA(80%胜率/+10.22%vs无基因70%/+6.75%),×2.5重仓它们:+38.9%回撤1.8% 优于原版+25.6%回撤2.8%", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"dna_boost": 2.5}), "v11.0": _v40_branch("v11.0", "波段·枢轴版", "v8.1入场;出场改枢轴体系:破弱支撑先出,收复弱压且创新高再进", "v8.1的MA10只是弱支撑的粗糙代理——用实盘同款枢轴S/R验证复合技术位是否优于简单均线", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"tp_pct": None, "exit_mode": "swing_pivot", "sl_atr": 1.5, "max_hold_days": 60, "reentry_days": 10}), "v11.1": _v40_branch("v11.1", "强压止盈弱撑止损", "v7.1入场;出场改实盘口径:强压止盈+弱支撑止损(枢轴点体系,替代固定15%/1.5ATR)", "实盘策略的真实出场方式就是枢轴S/R——回测必须验证这个口径而非固定百分比", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"tp_pct": None, "use_pivot_sr": True, "max_hold_days": 20}), "v11.2": _v40_branch("v11.2", "真波段+枢轴减半", "v8.1真波段(MA10先出再进) + 弱支撑止损 + 强压减半仓——取MA10贴身波段与枢轴风控两者之长", "v11.0的'枢轴波段'实为纯硬扛(弱撑从不触发),v8.1才是真波段(44次先出10次再进);用枢轴弱撑做初始止损、强压减半落袋改善风险结构", entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides={"tp_pct": None, "exit_mode": "swing_ptp", "sl_atr": 1.5, "max_hold_days": 60, "reentry_days": 10}), # ── 港股专用版本(港股通宇宙归因推导,2026-07-29)── "h1.0": { "version": "h1.0", "name": "港股v1-全堆(过拟合教训)", "summary": "v7.1港股化全堆版:ATR无上限+量比无上限+MACD窄幅0~0.3+周线向上——仅2笔交易", "hypothesis": "失败案例存档:把港股归因所有信号全堆导致过拟合(2笔),证明弱信号(MACD窄幅±3pp)不能进过滤器", "parent": "v7.1", "created": "2026-07-29", "config": { "entry": {"min_score": 45, "min_momentum": 8, "filters": {"adx_min": 20, "atr_pct_min": 2.8, "roc_min": 8, "macd_hist_min": 0, "macd_hist_max": 0.3, "dist_ma20_min": 4, "vol_ratio_min": 1.2, "ma20_slope_max": 1.5, "mkt_above_ma20": True, "mkt_slope_max": -0.05, "hh_only": True, "hl_only": True, "rsi_delta_min": 6, "weekly_up": True}}, "exit": {"tp_pct": 0.15, "sl_atr": 1.5, "max_hold_days": 20}, "sizing": {"kelly": True, "kelly_fraction": 0.5}, "eval_step": 1, }, }, "h1.1": { "version": "h1.1", "name": "港股v1.1-三强信号", "summary": "v7.1港股化(仅三处): ATR无上限+量比无上限+周线必须向上", "hypothesis": "港股8619笔归因的强信号: ATR上限失效(+10.7pp)、量比上限失效(+5.6pp)、weekly_up=True(+9pp)。MACD窄幅/ADX等为弱信号不过滤——只改三处避免过拟合(h1.0全堆仅2笔的教训)", "parent": "v7.1", "created": "2026-07-29", "config": { "entry": {"min_score": 45, "min_momentum": 8, "filters": {"adx_min": 20, "atr_pct_min": 3.5, "roc_min": 10, "roc_max": 25, "macd_hist_min": 0.25, "macd_hist_max": 1.3, "dist_ma20_min": 4, "vol_ratio_min": 0.9, "ma20_slope_max": 1.5, "mkt_above_ma20": True, "mkt_slope_max": -0.05, "hh_only": True, "hl_only": True, "rsi_delta_min": 6, "weekly_up": True}}, "exit": {"tp_pct": 0.15, "sl_atr": 1.5, "max_hold_days": 20}, "sizing": {"kelly": True, "kelly_fraction": 0.5}, "eval_step": 1, }, }, }) # ══════════════════════════════════════════════════════ # 策略详尽说明(给老爸和未来的我看 — 2026-07-30 记录) # ══════════════════════════════════════════════════════ STRATEGY_DESCRIPTIONS = { "v1.0": { "title": "多因子基线(五因子评分≥45 + 动量≥8)", "algorithm": "入场: 五因子评分(趋势0-25+动量0-20+量能0-20+波动率0-15+风险0-20)≥45 且动量分≥8。出场: 10%止盈 / 2×ATR止损 / 20天。仓位: 半Kelly。", "rationale": "验证多因子评分体系的基础有效性,是所有版本的基线参照。", "evidence": "2年55.3%/+28.2%(年化12.8%), 5年46.9%/-10.5%(年化-2.2%)。高频(1万+笔/2年)靠量取胜, 跨周期亏损。费用-11pp是高频致命伤。", }, "v2.0": { "title": "趋势动能过滤(v1+MACD柱>0+ROC>2+ADX≥20+ATR%≥2.8)", "algorithm": "v1.0基础上加: MACD柱>0, ROC>2, ADX≥20(趋势强度), ATR%≥2.8(排除死鱼股)。", "rationale": "v1归因: MACD>0.69胜率54%vs33%, ROC>11胜率57%vs35%。过滤无趋势/无动能/死鱼股。", "evidence": "2年胜率52.9%/+24.4%。过滤后交易减半但胜率+6pp。", }, "v3.0": { "title": "强动量+优盈亏比(ROC≥8+距MA20≥4%+量比1.0~1.8;止盈15%/止损1.5ATR)", "algorithm": "v2 + ROC≥8, dist_ma20≥4%, vol_ratio 1.0~1.8。出场改: 15%止盈/1.5×ATR止损(RR从1.1:1提到2.2:1)。", "rationale": "v2归因: 均亏-9.14%≈止盈10%, RR仅1.1:1是盈亏比恶化主因。收紧止损放大止盈。", "evidence": "2年+37.9%(年化16.9%)。均赢14.4%vs均亏-7.7%。夏普从-0.10→+1.57。", }, "v4.0": { "title": "大盘回调+趋势结构(v3+大盘MA20上+斜率<0+hh结构)", "algorithm": "v3 + 大盘在MA20上且MA20斜率<-0.05(上升中回调) + 个股更高高点(hh)结构。", "rationale": "v3归因: 大盘MA20斜率-1.76~-0.56时胜率58.8%vs平坡20.6%(差38pp最强信号)。回调买是核心边缘。", "evidence": "仅5笔交易(80%胜率/+9.51%)。过滤器叠加过拟合的教训——分桶样本仅36笔/桶时不能叠加6个过滤器。", }, "v4.0f": { "title": "单放量比(v4.0消融: 量比0.9~2.0)", "algorithm": "v4.0只放宽量比带1.2~1.5→0.9~2.0, 其余全保持。", "rationale": "消融实验找瓶颈: 量比带是v4.0最大限制。", "evidence": "5笔→15笔, 胜率73.3%/盈亏比2.92。证明量比窄带误杀高质量交易。", }, "v5.0": { "title": "量比+ATR融合(v4.0+量比0.9~2.0+ATR2.8~6.5)", "algorithm": "v4.0 + 量比0.9~2.0 + ATR% 2.8~6.5(消融胜出的双放宽融合)。", "evidence": "24笔/66.7%胜率/+6.03%。但21/24笔集中2026-04月——单一市场情景,普适性差。", }, "v6.0": { "title": "资金流过滤(v4.0f+资金5日净占比>-2.5)", "algorithm": "v4.0f + flow_5d>-2.5(排除主力持续流出)。", "rationale": "资金流归因: flow_5d<-2.7的持续流出组胜率仅37.5%, 其余55-78%。", "evidence": "34笔/70.6%胜率/+7.93%。资金数据仅2025-11月起, 5年无法验证。", }, "v6.1": { "title": "板块不追高(v4.0f+板块MA20斜率≤1.0)", "algorithm": "v4.0f + 板块MA20斜率≤1.0(排除已强涨板块)。", "rationale": "板块归因: sector_slope>1.04的强涨板块入场胜率仅27.3%, 回调/横盘板块80%。与大盘/个股'回调买'三层同构。", "evidence": "2年148笔/61.5%/+5.17%, 5年+12.9%(年化2.4%)。v6.1曾被误为最强(数据未统一时), 现证实v6.1-2.5+板块斜率≤1.0)", "algorithm": "v4.0f + flow_5d>-2.5 + 板块MA20斜率≤1.0。", "evidence": "28笔/82.1%胜率/+10.07%/回撤9.68%。但仅2月分布=单窗口运气, 置信系数×0.23打落榜首。", }, "v7.0": { "title": "分批止盈(v6.1+50%@+8%落袋+50%@+15%)", "algorithm": "v6.1入场不变; 出场改分批: 50%仓在+8%落袋, 50%仓在+15%落袋, 止损不变。", "rationale": "亏损尸检: 12笔亏损9笔为止损出局——先到+8%落袋一半可将部分止损单转为盈利单。", "evidence": "2年胜率64.2%→72.5%但均收益5.94%→4.95%(截断大赢家)。胜率换收益的典型。", }, "v7.1": { "title": "尸检因子过滤(v6.1+hl结构+RSI增量≥6)【当前实盘基线】", "algorithm": "入场: 评分≥45+动量≥8+ADX≥20+ATR3.5~5.5%+ROC10~25+MACD柱0.25~1.3+dist_ma20≥4%+量比0.9~2.0+ma20_slope≤1.5+大盘MA20上+大盘ADX≥20+大盘斜率≤-0.05+hh+hl+RSI增量≥6+板块斜率≤1.0。出场: 15%止盈/1.5×ATR止损/20天。", "rationale": "亏损尸检: 盈利组100%具备hl结构(亏损组仅75%), RSI增量赢11.3vs亏6.1——动量加速度区分输赢。", "evidence": "2年64.7%/+22.2%(年化10.2%), 5年56.3%/+29.3%(年化5.2%)。趋势强化后震荡年0笔、牛市85%。当前实盘策略。", }, "v7.1b": { "title": "v7.1+动量基因缩放(DNA票仓位×2.5)", "algorithm": "v7.1全执行; 收缩突破动量基因票(ATR处20日最低1/4位+破20日新高+量比>1.2)仓位×2.5, 其余×1.0。", "rationale": "v7.1的75笔中15笔带收缩突破DNA(80-91.7%胜率/+10.22% vs 无基因70%/+6.75%), ×2.5重仓它们。", "evidence": "原版+25.6%→缩放+38.9%, 回撤2.8%→1.8%。信念缩放完美兑现: 基因票更可靠, 重仓反而降风险。", }, "v7.2": { "title": "尸检+分批(v7.1入场+分批止盈出场)", "algorithm": "v7.1入场 + v7.0的分批止盈出场(50%@+8%+50%@+15%)。", "evidence": "2年74笔/79.7%胜率(最高)/+6.59%/回撤0.9%(最低)。稳定王但均收益被分批截断。", }, "v7.3": { "title": "资金+板块+分批(v6.2入场+分批出场)", "algorithm": "v6.2入场 + 分批止盈出场。", "evidence": "28笔/85.7%胜率(最高)/+7.92%/回撤9.68%。仅12月+数据, 小样本。", }, "v8.0": { "title": "趋势持有·结构出场(v7.1入场+破MA10两日/破MA20/横盘出货识别)", "algorithm": "v7.1入场; 出场改结构驱动: 连续2日收破MA10 或 收破MA20 或 高位放量滞涨(5日振幅<4%+均量>前20日1.3倍) → 出场。无固定目标, 最长40天。", "rationale": "固定15%目标截断利润——拉伸段不必早出场, 让利润奔跑至结构破位。", "evidence": "2年均亏-5.31%→-2.81%(砍亏最快), 盈亏比5.06(最高), 100%执行±0.0%方差(完全确定)。风控之王。", }, "v8.1": { "title": "波段先出再进(v7.1入场+MA10波段出场)【当前最优单策略】", "algorithm": "v7.1入场; 出场改波段: 跌破MA10先出(锁利润), 收回MA10且突破前一日高点再进(吃下一波), 各段复合, 最长60天。", "rationale": "拉伸段不出场, 调整时先出再进——破MA10锁定利润, 结构恢复再进场吃下一波。", "evidence": "2年70.6%/+37.5%(年化16.7%), 5年57.3%/+40.5%(年化6.9%)。均赢+21.78%(最高单笔经济性), 牛市100%全胜, 真波段(44次先出+10次再进验证)。收益王。", }, "v8.3": { "title": "波段40天(v8.1持仓60→40天)", "algorithm": "v8.1持仓期60天→40天。", "evidence": "+9.99%(vs v8.1的60天版+12.6%), 保住80%收益但资金周转快1/3。", }, "v9.0": { "title": "周线趋势过滤(v7.1+周线收盘须站上周线MA10)", "algorithm": "v7.1 + 周线close>周线MA10(中期趋势向上才买)。", "evidence": "23笔/60.9%。被证据否决: v7.1交易中weekly_up=False胜率78.4%vsTrue60.9%——周线破位恰恰是回调买点本身, 与回调买入逻辑冲突。", }, "v9.1": { "title": "周线多头排列(v7.1+周线MA10>MA20)", "algorithm": "v7.1 + 周线MA10>MA20(周线均线多头排列)。", "evidence": "59笔/69.5%/+6.92%。比weekly_up更严但同样与回调逻辑部分冲突。", }, "v9.2": { "title": "周线破位日线强(v7.1+周线收盘须低于周线MA10)", "algorithm": "v7.1 + 周线close<周线MA10(反向利用: 周线级回调中的日线动量回归正是回调策略核心边缘)。", "rationale": "v9归因反用: v7.1交易中weekly_up=False胜率78.4%vsTrue60.9%。", "evidence": "2年/5年都是72.5%胜率(跨周期最高), 回撤0.9%(最低), 2023震荡年0笔(完全避开)。稳定王/防守首选。", }, "v11.0": { "title": "波段·枢轴版(v8.1入场+破弱支撑先出/收复弱压再进)", "algorithm": "v8.1入场; 出场改枢轴体系: 破弱支撑(2×PP-H)先出, 收复弱压(2×PP-L)且创新高再进。", "evidence": "3仓+116.4%/回撤1.7%但实为'纯趋势硬扛60天'(弱撑随趋势上移从不触发, swing从未发生), 非真波段。集中运气, 全参与仅+28.9%现原形。", }, "v11.1": { "title": "强压止盈弱撑止损(v7.1入场+枢轴强压止盈/弱支撑止损)", "algorithm": "v7.1入场; 出场改实盘口径: 强压(PP+有效区间)止盈 + 弱支撑(2×PP-H)止损。", "evidence": "+52.0%vs v7.1固定出场的+74.6%——裸枢轴强压太远(够不着)、弱撑太近(老被洗), 固定15%/1.5ATR更优。证明固定百分比出场比裸枢轴更适合v7.1类策略。", }, "v11.2": { "title": "真波段+枢轴减半(v8.1+弱支撑止损+强压减半仓)【已否决】", "algorithm": "v8.1真波段 + 弱支撑止损 + 强压减半仓。", "evidence": "77%被洗出场(弱支撑日内贴身位当止损=自杀)。强压减半砍掉大赢腿(+32.4%vs v8.1的+93.2%)。枢轴S/R不适用于出场。", }, "v_combo": { "title": "组合·v8.1波段+B20动量(并集)", "algorithm": "v8.1回调入场(波段出场) + B严格收缩突破动量入场(ATR最低1/4位+破20日新高+量比>1.5+评分≥50+ROC>4+大盘MA20上+大盘ADX≥20, 20%宽止盈/1.5ATR止损), 两族信号并集去重。", "rationale": "回调族(v7.1系)与动量族(B突破)信号零重叠——一个抓跌出来的机会, 一个抓涨出来的机会, 两行情段全覆盖。", "evidence": "2年219笔/57.5%/+7.10%/组合+47.9%/回撤1.6%/98%执行。但5年验证2023/2024震荡转折年亏损(B动量族假突破)——v8.1主策略, B只配强牛增强。", }, "v_next": { "title": "v8.1+三重信念×2(DNA+行业ADX≥20+资金加速度, 均衡版)【当前最优】", "algorithm": "v8.1波段出场基座 + 三重已验证信念信号分级重仓(×2): 动量基因(DNA,62.5%胜率)/行业趋势强(行业ADX≥20,69%胜率+16.31%)/资金加速(flow_delta>0,100%胜率+16.74%)。其余×1.0。", "rationale": "v8.1波段单笔最优(均赢+21.78%), 叠加三重信念放大优质交易——分得清好坏才配重仓。", "evidence": "全参与+48.2%(年化8.8%)/回撤4.1%。×2票69%胜率/+14.23% vs ×1票48%/+5.99%。收益最高且可解释可复现经5年验证。当前最优策略。", }, "h1.0": { "title": "港股v1-全堆(过拟合教训)", "algorithm": "v7.1港股化全堆: ATR无上限+量比无上限+MACD窄幅0~0.3+周线向上。", "evidence": "仅2笔交易——过滤器全堆导致过拟合, 证明弱信号(MACD窄幅±3pp)不能进过滤器。", }, "h1.1": { "title": "港股v1.1-三强信号(ATR/量比无上限+周线向上)", "algorithm": "v7.1港股化仅三处: ATR无上限+量比无上限+周线必须向上。", "evidence": "27笔/59.3%/+4.56%——与v7.1港股基线(26笔/61.5%/+4.03%)打平, 港股无需单独调参。", }, } def get_strategy(version): if version not in STRATEGIES: raise ValueError(f"未知策略版本: {version},可用: {list(STRATEGIES.keys())}") return STRATEGIES[version] # 各策略的最优仓位模型(仓位扫描实证,2026-07-29) # 波段/结构出场适合大仓少股,固定出场适合小仓多股 def _v81_conviction(version, name, summary, hypothesis, conviction, exit_overrides=None): """v8.1波段基座 + 信念分级仓位配置""" exit_cfg = {"tp_pct": None, "exit_mode": "swing", "sl_atr": 1.5, "max_hold_days": 60, "reentry_days": 10} if exit_overrides: exit_cfg.update(exit_overrides) s = _v40_branch(version, name, summary, hypothesis, entry_overrides={"vol_ratio_min": 0.9, "vol_ratio_max": 2.0, "sector_slope_max": 1.0, "hl_only": True, "rsi_delta_min": 6}, exit_overrides=exit_cfg) s['config']['conviction'] = conviction s['created'] = '2026-07-30' return s STRATEGIES.update({ "v_next": _v81_conviction("v_next", "v8.1+三重信念×2(平铺)", "v8.1波段基座; 任一信念因子命中(DNA/行业ADX>=20/资金加速度)仓位×2, 不叠乘", "v8.1波段单笔最优(均赢+21.78%), 信念因子区分好坏票——命中即加倍, 简单稳健", {"model": "平铺: DNA or 行业ADX>=20 or flow_delta>0 → ×2", "stack": False, "dna_mult": 2.0, "sector_adx_min": 20, "sector_mult": 2.0, "flow_delta_min": 0, "flow_mult": 2.0}), "v_next4": _v81_conviction("v_next4", "v8.1+四维信念叠乘+长波段", "v8.1波段基座(持仓80天/再进15天); DNA×2+行业ADX>25×2+flow_delta>0×2+新闻正向×2, 叠乘封顶×4", "h80_r15组合验证最优: 60天持仓砍大赢腿, 80天+15天再进窗口捕获更多趋势段", {"model": "叠乘封顶×4: DNA×2 + 行业ADX>25×2 + flow_delta>0×2 + 新闻正向×2", "stack": True, "cap": 4.0, "dna_mult": 2.0, "sector_adx_min": 25, "sector_mult": 2.0, "flow_delta_min": 0, "flow_mult": 2.0, "news_mult": 2.0, "news_days": 3}, exit_overrides={"max_hold_days": 80, "reentry_days": 15}), "v_next3": _v81_conviction("v_next3", "v8.1+信念叠乘+行业牛杠杆", "v8.1波段基座; DNA×2 + 行业ADX>25×2 + flow_delta>0×2 叠乘, 封顶×4", "多因子共振的票才是极品配最重仓; 行业ADX>25=行业级确认牛, 比>=20更精准", {"model": "叠乘封顶×4: DNA×2 + 行业ADX>25×2 + flow_delta>0×2 + 新闻正向×2", "stack": True, "cap": 4.0, "dna_mult": 2.0, "sector_adx_min": 25, "sector_mult": 2.0, "flow_delta_min": 0, "flow_mult": 2.0, "news_mult": 2.0, "news_days": 3}), }) STRATEGY_DESCRIPTIONS.update({ "v_next": { "title": "v8.1+三重信念×2 平铺版(DNA/行业ADX≥20/资金加速度, 任一命中即×2)", "algorithm": "v8.1波段出场基座 + 三重信念信号平铺重仓: 动量基因DNA、行业趋势强(行业ADX≥20)、资金加速(flow_delta>0)——任一命中仓位×2, 多命中不叠乘。基准3仓。", "rationale": "v8.1波段单笔最优(均赢+21.78%), 信念因子区分好坏票——命中即加倍, 规则简单稳健。与v_next3的区别: 平铺只分'有没有信念', 不区分'信念有多强'。", "evidence": "5年全参与+48.3%(年化8.8%)/回撤4.1%。×2票69%胜率/+14.23% vs ×1票48%/+5.99%。规则经逆向验证103/103笔精确复现(平铺×2)。", }, "v_next4": { "title": "v8.1+四维信念叠乘+长波段(h80/r15, DNA×2·行业ADX>25×2·资金加速度×2·新闻正向×2, 封顶×4)【当前最优】", "algorithm": "v8.1波段出场基座(持仓80天/再进窗口15天) + 四重信念因子叠乘重仓: ①动量基因DNA(收缩突破)×2 ②行业确认牛(行业ADX>25)×2 ③资金加速度(flow_delta>0)×2 ④新闻正向(个股+行业3日内正向新闻)×2; 多因子同时命中则叠乘, 封顶×4。基准3仓。", "rationale": "v_next3的60天持仓限制在趋势中段砍大赢腿。80天持仓+15天再进窗口让波段充分展开, 捕获更多趋势段。信念因子不变。", "evidence": "5年103笔/57.3%胜率, 全参与+81.6%(年化12.5%)/回撤5.5%, 收益/回撤比14.8。比v_next3基线+17pp(64.6%→81.6%)。2y全参与+77.0%(年化31.9%/回撤0.2%)。", }, "v_next3": { "title": "v8.1+四维信念叠乘(DNA×2·行业ADX>25×2·资金加速度×2·新闻正向×2, 封顶×4)", "algorithm": "v8.1波段出场基座 + 四重信念因子叠乘重仓: ①动量基因DNA(收缩突破)×2 ②行业确认牛(行业ADX>25)×2 ③资金加速度(flow_delta>0)×2 ④新闻正向(个股+行业3日内正向新闻)×2; 多因子同时命中则叠乘, 封顶×4; 均不命中×1。基准3仓, 单票最大4倍基准仓。", "rationale": "v_next平铺只区分'有没有信念', 叠乘区分'信念有多强'——多因子共振的票是极品, 配最重仓位。新闻因子验证: 入场前3日有正向新闻胜率73% vs 无新闻44%(基线57%)。", "evidence": "5年103笔/57.3%胜率, 全参与+64.6%(年化10.3%)/回撤3.6%, 收益/回撤比17.9全场最优。boost分布×1:33笔/×2:44笔/×4:26笔。新闻因子使5y收益+9.8pp(54.8%→64.6%)。", }, }) STRATEGY_SIZING = { 'v7.1': 4, # 固定15%出场,4仓+74.6%最优(10仓+59.8%) 'v7.2': 3, # 分批止盈,3仓+60.7% 'v8.0': 3, # 结构持有,3仓+53.2% 'v8.1': 3, # 波段MA10,3仓+102.9% 'v8.3': 3, # 波段40天,3仓+62.1% 'v9.2': 5, # 周线破位,5仓+47.6% 'v6.1': 5, # 板块不追高,5仓+47.3% 'v7.1b': 4, # 同v7.1,4仓 'v11.0': 3, # 枢轴波段,3仓+116.4% 'v11.1': 3, # 枢轴强压/弱撑,3仓+55.8% 'v_next': 3, # 信念平铺×2,同v8.1基座3仓 'v_next3': 3, # 信念叠乘封顶×4,同v8.1基座3仓 'v_next4': 3, # 同v_next3,3仓 } # ══════════════════════════════════════════════════════ # 大盘 / 行业上下文 # ══════════════════════════════════════════════════════ _MKT_CTX = {} _SECTOR_CTX = {} _STOCK_SECTOR = {} def prepare_market_context(start_date, end_date): """大盘指数每日状态: A股用上证(sh000001),港股用恒生(hkHSI)""" global _MKT_CTX, _MKT_CTX_HK _MKT_CTX = _load_index_ctx('sh000001', start_date, end_date) _MKT_CTX_HK = _load_index_ctx('hkHSI', start_date, end_date) def _load_index_ctx(index_code, start_date, end_date): ctx = {} bars = prepare_bars(index_code, start_date, end_date) if not bars: return ctx for i, b in enumerate(bars): slope = None if i >= 5: m0, m1 = bars[i-5].get('ma20'), b.get('ma20') if m0 and m1: slope = round((m1 - m0) / m0 * 100, 3) ma20 = b.get('ma20') or 0 ctx[b['date']] = { 'above_ma20': (b.get('close') or 0) > ma20 if ma20 > 0 else None, 'ma20_slope': slope, 'roc': b.get('roc'), 'adx': b.get('adx'), # 趋势强度(choppy市<20,趋势市>25,2026-07-30强化趋势过滤) } return ctx def is_hk_code(code): return len(code) == 5 and code.startswith('0') def prepare_sector_context(start_date, end_date): """行业上下文: sector_index_daily(全历史) 提供板块趋势; sector_snapshots(近期) 补充净流入""" global _SECTOR_CTX, _STOCK_SECTOR, _SECTOR_DATES _SECTOR_CTX, _STOCK_SECTOR = {}, {} conn = sqlite3.connect(DB_PATH) try: # 1. 板块指数历史(全周期) try: idx_rows = conn.execute( "SELECT sector, date, close, change_pct, high, low FROM sector_index_daily " "WHERE date >= ? AND date <= ? ORDER BY sector, date", (start_date, end_date)).fetchall() except sqlite3.OperationalError: idx_rows = [] # 每板块计算 MA20 和斜率 from collections import defaultdict by_sector = defaultdict(list) sec_hl = defaultdict(list) for row in idx_rows: sec, d, close, chg = row[0], row[1], row[2], row[3] by_sector[sec].append((d, close, chg)) if len(row) >= 6: sec_hl[sec].append((d, row[4], row[5])) for sec, series in by_sector.items(): closes = [c for _, c, _ in series] for i, (d, close, chg) in enumerate(series): above = slope = None if i >= 19: ma20 = sum(closes[i-19:i+1]) / 20 above = close > ma20 if i >= 24: ma20_5 = sum(closes[i-24:i-4]) / 20 if ma20_5 > 0: slope = round((ma20 - ma20_5) / ma20_5 * 100, 3) _adx = None _hl = sec_hl.get(sec, []) if len(_hl) >= 20 and i >= 14: from backtest_framework import calc_trend_strength _hs = [x[1] for x in _hl] _ls = [x[2] for x in _hl] _cs = [c for _, c, _ in series] if len(_cs) == len(_hl): _av = calc_trend_strength(_hs, _ls, _cs, 14) if i < len(_av): _adx = _av[i] _SECTOR_CTX.setdefault(d, {})[sec] = { 'change': chg, 'above_ma20': above, 'slope': slope, 'adx': _adx, } # 2. sector_snapshots 补充净流入和涨幅(近期,THS命名) snap_rows = conn.execute(""" SELECT substr(m.timestamp,1,10) as d, s.name, AVG(s.change_pct), SUM(s.net_inflow) FROM sector_snapshots s JOIN market_snapshots m ON s.snapshot_id = m.id WHERE m.timestamp >= ? AND m.timestamp <= ? GROUP BY d, s.name """, (start_date, end_date + ' 23:59')).fetchall() for d, name, chg, inflow in snap_rows: e = _SECTOR_CTX.setdefault(d, {}).setdefault(name, {}) e['inflow'] = round(inflow or 0, 1) if 'change' not in e or e.get('change') is None: e['change'] = round(chg or 0, 2) # 3. 个股→行业映射:优先 EM 体系(覆盖全,与 sector_index_daily 对齐),THS/港股体系兜底 try: for code, sec in conn.execute("SELECT code, sector FROM stock_sectors_em").fetchall(): _STOCK_SECTOR[code] = sec except sqlite3.OperationalError: pass for code, sec, src in conn.execute( "SELECT code, sector_name, source FROM stock_sectors").fetchall(): if code not in _STOCK_SECTOR and src in ('ths', 'hk_em', 'hk_manual'): _STOCK_SECTOR[code] = sec _SECTOR_DATES = sorted(_SECTOR_CTX.keys()) finally: conn.close() def mkt_ctx(date, code=None): """按市场取大盘情景:港股用恒指,A股用上证""" if code and is_hk_code(code): return _MKT_CTX_HK.get(date, {}) return _MKT_CTX.get(date, {}) _SECTOR_DATES = [] # sorted list of dates present in _SECTOR_CTX def sector_ctx(code, date): sec = _STOCK_SECTOR.get(code) if not sec or not _SECTOR_DATES: return {} import bisect i = bisect.bisect_right(_SECTOR_DATES, date) - 1 # 从 <= date 向前回溯,按键合并:snapshot 日期可能只有部分行业/字段, # 取每个字段最近一次出现的值(inflow/change 来自 snapshot,adx/slope 来自板块指数) result = {} while i >= 0: v = _SECTOR_CTX[_SECTOR_DATES[i]].get(sec) if v: for k, val in v.items(): result.setdefault(k, val) if 'adx' in result and ('change' in result or 'inflow' in result or 'slope' in result): break i -= 1 return result # ══════════════════════════════════════════════════════ # 资金面上下文(stock_capital_flow 表) # ══════════════════════════════════════════════════════ _FLOW_CTX = {} # code -> {date: main_pct} _FLOW_SORTED = {} # code -> sorted list of (date, main_pct) def prepare_flow_context(start_date, end_date): """加载个股主力资金净流入净占比历史""" global _FLOW_CTX, _FLOW_SORTED _FLOW_CTX, _FLOW_SORTED = {}, {} conn = sqlite3.connect(DB_PATH) try: rows = conn.execute(""" SELECT code, date, main_pct FROM stock_capital_flow WHERE date >= ? AND date <= ? """, (start_date, end_date)).fetchall() except sqlite3.OperationalError: rows = [] # 表不存在时容忍 finally: conn.close() for code, d, pct in rows: _FLOW_CTX.setdefault(code, {})[d] = pct for code, dmap in _FLOW_CTX.items(): _FLOW_SORTED[code] = sorted(dmap.items()) def flow_ctx(code, date, bars_dates, idx): """资金流因子: 当日净占比 / 5日均值 / 5日趋势""" series = _FLOW_SORTED.get(code) if not series: return {} dmap = _FLOW_CTX[code] # 找 date 之前(含)最近5个资金流数据点 dates = [d for d, _ in series if d <= date] if not dates: return {} recent = dates[-5:] prior = dates[-10:-5] f = {'flow_pct': dmap.get(dates[-1])} if recent: vals = [dmap[d] for d in recent if dmap.get(d) is not None] f['flow_5d'] = round(sum(vals) / len(vals), 2) if vals else None if recent and prior: v5 = [dmap[d] for d in recent if dmap.get(d) is not None] p5 = [dmap[d] for d in prior if dmap.get(d) is not None] if v5 and p5: f['flow_delta'] = round(sum(v5)/len(v5) - sum(p5)/len(p5), 2) return f # ══════════════════════════════════════════════════════ # 多周期(周线)上下文 # ══════════════════════════════════════════════════════ _WEEKLY_CTX = {} # code -> sorted [(date, close, ma10w, ma20w)] # ══════════════════════════════════════════════════════ # 新闻情绪上下文(stock_news 表,同花顺+东财) # ══════════════════════════════════════════════════════ _NEWS_CTX = {} # code -> sorted [(date_str, sentiment)] _NEWS_DATES = [] # sorted dates _SECTOR_NEWS_CTX = {} # sector -> sorted [(date_str, sentiment)] def prepare_news_context(start_date, end_date): """加载个股新闻情绪(关键词分类)""" global _NEWS_CTX, _NEWS_DATES, _SECTOR_NEWS_CTX _NEWS_CTX, _SECTOR_NEWS_CTX = {}, {} conn = sqlite3.connect(DB_PATH) try: rows = conn.execute(""" SELECT code, SUBSTR(date,1,10) as d, title, content FROM stock_news WHERE date >= ? AND date <= ? ORDER BY code, date """, (start_date, end_date)).fetchall() except sqlite3.OperationalError: rows = [] finally: conn.close() POS = ['涨停','大涨','预增','中标','增持','回购','利好','创新高','突破','净买入','资金流入','机构买入','看多','上调','回暖','复苏','放量'] NEG = ['跌停','大跌','预亏','减持','亏损','利空','立案','风险','下调','净卖出','资金流出','机构卖出','看空','退市','下滑','萎缩'] from collections import defaultdict by_code = defaultdict(list) for code, d, title, content in rows: text = (title or '') + ' ' + (content or '') pos = sum(1 for kw in POS if kw in text) neg = sum(1 for kw in NEG if kw in text) if pos > neg: sent = 'positive' elif neg > pos: sent = 'negative' else: continue # 中性不存 by_code[code].append((d, sent)) for code, lst in by_code.items(): _NEWS_CTX[code] = lst # 行业聚合 try: conn = sqlite3.connect(DB_PATH) sector_map = dict(conn.execute("SELECT code, sector FROM stock_sectors_em").fetchall()) conn.close() sector_news = defaultdict(list) for code, lst in by_code.items(): sec = sector_map.get(code) if sec: for d, sent in lst: sector_news[sec].append((d, sent)) for sec, lst in sector_news.items(): _SECTOR_NEWS_CTX[sec] = sorted(lst) except: pass _NEWS_DATES = sorted({d for lst in by_code.values() for d, _ in lst}) def news_ctx(code, date, days=3): """返回个股近N日新闻情绪统计""" from datetime import datetime, timedelta d_end = date d_start = (datetime.strptime(date, '%Y-%m-%d') - timedelta(days=days)).strftime('%Y-%m-%d') lst = _NEWS_CTX.get(code, []) pos = sum(1 for d, s in lst if d_start <= d < d_end and s == 'positive') neg = sum(1 for d, s in lst if d_start <= d < d_end and s == 'negative') return {'pos': pos, 'neg': neg, 'total': pos + neg} def sector_news_ctx(sector, date, days=3): """返回行业近N日新闻情绪统计""" from datetime import datetime, timedelta d_end = date d_start = (datetime.strptime(date, '%Y-%m-%d') - timedelta(days=days)).strftime('%Y-%m-%d') lst = _SECTOR_NEWS_CTX.get(sector, []) pos = sum(1 for d, s in lst if d_start <= d < d_end and s == 'positive') neg = sum(1 for d, s in lst if d_start <= d < d_end and s == 'negative') return {'pos': pos, 'neg': neg, 'total': pos + neg} def prepare_weekly_context(start_date, end_date): """加载 stock_weekly,计算每周 MA10/MA20""" global _WEEKLY_CTX _WEEKLY_CTX = {} conn = sqlite3.connect(DB_PATH) try: rows = conn.execute(""" SELECT code, date, close FROM stock_weekly WHERE date >= ? AND date <= ? ORDER BY code, date """, (start_date, end_date)).fetchall() except sqlite3.OperationalError: rows = [] finally: conn.close() from collections import defaultdict by_code = defaultdict(list) for code, d, close in rows: by_code[code].append((d, close)) for code, series in by_code.items(): closes = [c for _, c in series] out = [] for i, (d, close) in enumerate(series): ma10 = sum(closes[max(0, i-9):i+1]) / len(closes[max(0, i-9):i+1]) if i >= 4 else None ma20 = sum(closes[max(0, i-19):i+1]) / len(closes[max(0, i-19):i+1]) if i >= 10 else None out.append((d, close, ma10, ma20)) _WEEKLY_CTX[code] = out def weekly_ctx(code, date): """取 date 之前最近一根完整周线的状态""" series = _WEEKLY_CTX.get(code) if not series: return {} last = None for d, close, ma10, ma20 in series: if d < date: # 只用已完成的周线(不含当周) last = (d, close, ma10, ma20) else: break if not last: return {} _, close, ma10, ma20 = last f = {} if ma10: f['weekly_up'] = close > ma10 # 周线站上MA10 = 中期趋势向上 f['weekly_dist'] = round((close - ma10) / ma10 * 100, 2) if ma10 and ma20: f['weekly_aligned'] = ma10 > ma20 # 周线多头排列 return f # ══════════════════════════════════════════════════════ # 入场过滤器 # ══════════════════════════════════════════════════════ def pass_filters(factors, filters): if not filters: return True def chk(key, vmin=None, vmax=None): v = factors.get(key) if vmin is not None and (v is None or v < vmin): return False if vmax is not None and v is not None and v > vmax: return False return True if not chk('rsi', filters.get('rsi_min'), filters.get('rsi_max')): return False if not chk('adx', filters.get('adx_min'), filters.get('adx_max')): return False if not chk('dist_ma20', filters.get('dist_ma20_min'), filters.get('dist_ma20_max')): return False if not chk('vol_ratio', filters.get('vol_ratio_min'), filters.get('vol_ratio_max')): return False if not chk('roc', filters.get('roc_min'), filters.get('roc_max')): return False if not chk('atr_pct', filters.get('atr_pct_min'), filters.get('atr_pct_max')): return False if not chk('macd_hist', filters.get('macd_hist_min'), filters.get('macd_hist_max')): return False # 趋势变化 if not chk('ma20_slope', filters.get('ma20_slope_min'), filters.get('ma20_slope_max')): return False if not chk('macd_hist_delta', filters.get('macd_hist_delta_min'), filters.get('macd_hist_delta_max')): return False if not chk('rsi_delta', filters.get('rsi_delta_min'), filters.get('rsi_delta_max')): return False if filters.get('adx_rising') and not factors.get('adx_rising'): return False if filters.get('trend_only') and not factors.get('trend_aligned'): return False if filters.get('hh_only') and not factors.get('hh_structure'): return False if filters.get('hl_only') and not factors.get('hl_structure'): return False if filters.get('no_new_high') and factors.get('near_high_20d'): return False # 大盘 if filters.get('mkt_above_ma20') and factors.get('mkt_above_ma20') is not True: return False if not chk('mkt_slope', filters.get('mkt_slope_min'), filters.get('mkt_slope_max')): return False if not chk('mkt_adx', filters.get('mkt_adx_min'), filters.get('mkt_adx_max')): return False # 行业 if not chk('sector_change', filters.get('sector_change_min'), filters.get('sector_change_max')): return False if not chk('sector_rank_pct', None, filters.get('sector_rank_pct_max')): return False if not chk('sector_slope', filters.get('sector_slope_min'), filters.get('sector_slope_max')): return False if filters.get('sector_above_ma20') and factors.get('sector_above_ma20') is not True: return False # 资金面 if not chk('flow_pct', filters.get('flow_pct_min'), filters.get('flow_pct_max')): return False if not chk('flow_5d', filters.get('flow_5d_min'), filters.get('flow_5d_max')): return False if not chk('flow_delta', filters.get('flow_delta_min'), filters.get('flow_delta_max')): return False # 多周期 if filters.get('weekly_up') and factors.get('weekly_up') is not True: return False if filters.get('weekly_aligned') and factors.get('weekly_aligned') is not True: return False if filters.get('weekly_down_only') and factors.get('weekly_up') is not False: return False if not chk('weekly_dist', filters.get('weekly_dist_min'), filters.get('weekly_dist_max')): return False return True def calc_factors(bars, idx): """个股因子: 水平值 + 趋势变化""" b = bars[idx] prev5 = bars[max(0, idx-5)] close = b.get('close') or 0 ma20 = b.get('ma20') or 0 atr = b.get('atr') or 0 vol = b.get('volume') or 0 pvol = prev5.get('volume') or 0 window = bars[max(0, idx-19):idx+1] high20 = max((x.get('high') or 0) for x in window) if window else 0 ma5, ma10 = b.get('ma5') or 0, b.get('ma10') or 0 f = { 'rsi': b.get('rsi'), 'adx': b.get('adx'), 'macd_hist': b.get('macd_hist'), 'roc': b.get('roc'), 'atr_pct': round(atr / close * 100, 2) if close > 0 and atr else None, 'dist_ma20': round((close - ma20) / ma20 * 100, 2) if ma20 > 0 else None, 'vol_ratio': round(vol / pvol, 2) if pvol > 0 else None, 'obv_delta': (b.get('obv') or 0) - (prev5.get('obv') or 0), 'trend_aligned': ma5 > ma10 > ma20 > 0, 'near_high_20d': close >= high20 * 0.98 if high20 > 0 else False, } # 趋势变化因子(不能孤立看点值,要看方向和变化) if idx >= 5: b5 = bars[idx-5] m0, m1 = b5.get('ma20'), b.get('ma20') f['ma20_slope'] = round((m1 - m0) / m0 * 100, 3) if m0 and m1 else None h0, h1 = b5.get('macd_hist'), b.get('macd_hist') f['macd_hist_delta'] = round(h1 - h0, 3) if h0 is not None and h1 is not None else None a0, a1 = b5.get('adx'), b.get('adx') f['adx_rising'] = (a1 > a0) if a0 is not None and a1 is not None else None r0, r1 = b5.get('rsi'), b.get('rsi') f['rsi_delta'] = round(r1 - r0, 2) if r0 is not None and r1 is not None else None if idx >= 10: h5 = max(x.get('high') or 0 for x in bars[idx-4:idx+1]) h10 = max(x.get('high') or 0 for x in bars[idx-9:idx-4]) l5 = min(x.get('low') or 1e9 for x in bars[idx-4:idx+1]) l10 = min(x.get('low') or 1e9 for x in bars[idx-9:idx-4]) f['hh_structure'] = h5 > h10 # 更高的高点 = 上升结构 f['hl_structure'] = l5 > l10 # 更高的低点 = 上升结构 return f # ══════════════════════════════════════════════════════ # 回测引擎(配置驱动 + 12维上下文记录) # ══════════════════════════════════════════════════════ def has_breakout_dna(bars, i, lookback=10): """收缩突破动量基因:前lookback日内出现 ATR处20日最低1/3位 + 破20日新高 + 量比>1.2 (由果推因验证的早发现信号,2026-07-29)""" for k in range(max(25, i - lookback), i + 1): b = bars[k] atr_now = b.get('atr') or 0 atrs = [x.get('atr') or 0 for x in bars[k-20:k]] if not atrs: continue atr_low = sorted(atrs)[len(atrs)//3] high20 = max(x['high'] for x in bars[k-20:k]) vols = [x['volume'] for x in bars[k-5:k]] vm = sum(vols) / len(vols) if vols else 0 vr = (bars[k]['volume'] / vm) if vm > 0 else 1 if atr_now > 0 and atr_now <= atr_low * 1.1 and bars[k]['close'] > high20 and vr > 1.2: return True return False def run_backtest(strategy_version, start_date, end_date, capital=913000, save=True, universe='all', period_tag='2y'): strat = get_strategy(strategy_version) cfg = strat['config'] entry_cfg, exit_cfg = cfg['entry'], cfg['exit'] filters = entry_cfg.get('filters', {}) step = cfg.get('eval_step', 5) # 指标预热:取数往前多取120天,保证窗口首日 MA60/RSI/ATR 等已收敛 fetch_start = (datetime.strptime(start_date, '%Y-%m-%d') - timedelta(days=120)).strftime('%Y-%m-%d') prepare_market_context(fetch_start, end_date) prepare_sector_context(start_date, end_date) prepare_flow_context(fetch_start, end_date) prepare_weekly_context(fetch_start, end_date) prepare_news_context(start_date, end_date) conn = sqlite3.connect(DB_PATH) stocks = conn.execute(""" SELECT DISTINCT sd.code, COALESCE(s.name, sd.code) as name FROM stock_daily sd LEFT JOIN stocks s ON sd.code = s.code WHERE sd.date>=? AND sd.date<=? """, (start_date, end_date)).fetchall() conn.close() # 市场过滤:hk=仅港股, a=仅A股, all=全部 if universe == 'hk': stocks = [(c, n) for c, n in stocks if is_hk_code(c)] elif universe == 'a': stocks = [(c, n) for c, n in stocks if not is_hk_code(c)] trades = [] screened = scored_n = 0 for code, name in stocks: screened += 1 bars = _bars(code, fetch_start, end_date) if not bars or len(bars) < 25: continue i = 20 while i < len(bars): # 只在考察窗口内产生交易,预热期 bars 仅供指标计算 if bars[i].get('date', '') < start_date: i += 1 continue window = bars[:i+1] sc = compute_single_score(window) if sc is None: i += step continue total_score, comp = sc scored_n += 1 last = bars[i] close = last.get('close') or 0 if total_score >= entry_cfg['min_score'] and comp['momentum'] >= entry_cfg['min_momentum']: factors = calc_factors(bars, i) # 附加大盘/行业上下文(港股用恒指,A股用上证) date = last.get('date') mk = mkt_ctx(date, code) sc_ctx = sector_ctx(code, date) factors['mkt_above_ma20'] = mk.get('above_ma20') factors['mkt_slope'] = mk.get('ma20_slope') factors['mkt_roc'] = mk.get('roc') factors['mkt_adx'] = mk.get('adx') factors['sector_change'] = sc_ctx.get('change') factors['sector_rank_pct'] = sc_ctx.get('rank_pct') factors['sector_inflow'] = sc_ctx.get('inflow') factors['sector_above_ma20'] = sc_ctx.get('above_ma20') factors['sector_slope'] = sc_ctx.get('slope') factors['sector_adx'] = sc_ctx.get('adx') # 资金面因子 fl = flow_ctx(code, date, None, i) factors.update(fl) # 周线因子 factors.update(weekly_ctx(code, date)) if pass_filters(factors, filters): # ── 次日开盘价入场(杜绝"信号日收盘买"的未来幻觉)── if i + 1 >= len(bars): i += step continue ep = bars[i+1].get('open') or close _dna = has_breakout_dna(bars, i) atr_val = last.get('atr') or 0 if exit_cfg.get('use_pivot_sr'): # 实盘口径:强压止盈 + 弱支撑止损(枢轴点体系) target = last.get('strong_resist') if (last.get('strong_resist') or 0) > close else None stop = last.get('weak_support') if (last.get('weak_support') or 0) < close else (close - atr_val * exit_cfg.get('sl_atr', 1.5) if atr_val > 0 else close * 0.93) if target is None: target = close * (1 + exit_cfg.get('tp_pct', 0.15)) elif exit_cfg.get('tp_pct'): target = close * (1 + exit_cfg['tp_pct']) elif exit_cfg.get('tp_atr') and atr_val > 0: target = close + atr_val * exit_cfg['tp_atr'] elif exit_cfg.get('trail_atr'): target = None # 移动止盈模式无固定目标 else: target = close * 1.10 if not exit_cfg.get('use_pivot_sr'): if exit_cfg.get('sl_atr') and atr_val > 0: stop = close - atr_val * exit_cfg['sl_atr'] elif exit_cfg.get('sl_pct'): stop = close * (1 - exit_cfg['sl_pct']) else: stop = close * 0.93 kelly = 0 if cfg['sizing'].get('kelly'): rr_est = ((target - close) / close) if target else (2 * (close - stop) / close) kelly = compute_kelly(total_score, rr_est if close > 0 else 0.1, (close - stop) / close if close > 0 else 0.07) max_hold = exit_cfg.get('max_hold_days', 20) trail_atr = exit_cfg.get('trail_atr') staged_tp = exit_cfg.get('staged_tp') # [[frac, pct], ...] 分批止盈 future = bars[i+1:i+1+max_hold] exit_price = exit_reason = None hold_days = 0 highest_close = close if staged_tp: # ── 分批止盈模拟:按目标分批落袋,止损约束剩余仓位 ── remaining = 1.0 realized_pnl = 0.0 realized = [False] * len(staged_tp) for k, fb in enumerate(future): fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 # 保守假设:同日先触止损 if fl <= stop: realized_pnl += remaining * ((fc - close) / close) remaining = 0 exit_reason, hold_days = 'stop', k + 1 break for si, (frac, tp) in enumerate(staged_tp): if not realized[si] and fh >= close * (1 + tp): realized_pnl += frac * tp remaining -= frac realized[si] = True if remaining <= 1e-9: exit_reason, hold_days = 'target', k + 1 break if remaining > 1e-9: last_c = future[-1].get('close') if future else close realized_pnl += remaining * ((last_c - close) / close) hold_days = len(future) if exit_reason is None: exit_reason = 'staged_end' if any(realized) else 'keep' pnl = realized_pnl * 100 exit_price = close * (1 + realized_pnl) elif exit_cfg.get('exit_mode') == 'structure': # ── 趋势持有(筹码视角):无固定目标,破位/出货才走 ── below_ma10 = 0 dist_lookback = exit_cfg.get('dist_gain', 12) # 涨幅超此值才识别出货 for k, fb in enumerate(future): fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 fv = fb.get('volume') or 0 fma10, fma20 = fb.get('ma10') or 0, fb.get('ma20') or 0 if fl <= stop: exit_price, exit_reason, hold_days = fc, 'stop', k+1 break # 横盘出货识别:涨幅>12%后,5日振幅<4% 且 均量>前20日均量1.3倍 if k >= 5 and (fc - close)/close*100 > dist_lookback: recent = future[k-4:k+1] lo = min(x.get('low') or 1e9 for x in recent) hi = max(x.get('high') or 0 for x in recent) amp = (hi - lo)/lo*100 if lo > 0 else 99 avg_vol = sum(x.get('volume') or 0 for x in recent)/5 base_win = bars[max(0, i-19):i+1] base_vol = sum(x.get('volume') or 0 for x in base_win)/len(base_win) if base_win else 0 if amp < 4 and base_vol > 0 and avg_vol > 1.3*base_vol: exit_price, exit_reason, hold_days = fc, 'distribution', k+1 break # 结构破位:连续2日收破MA10,或单日收破MA20 if fma10 > 0 and fc < fma10: below_ma10 += 1 if below_ma10 >= 2: exit_price, exit_reason, hold_days = fc, 'ma10_break', k+1 break else: below_ma10 = 0 if fma20 > 0 and fc < fma20: exit_price, exit_reason, hold_days = fc, 'ma20_break', k+1 break if exit_price is None: exit_price = future[-1].get('close') if future else close exit_reason, hold_days = 'keep', len(future) pnl = (exit_price - close) / close * 100 if close > 0 else 0 elif exit_cfg.get('exit_mode') == 'swing': # ── 波段操作(先出再进):破MA10出,10日内收回MA10且创新高再进 ── reentry_window = exit_cfg.get('reentry_days', 10) legs = [] in_pos = True entry_p = close stop_cur = stop wait = 0 exit_reason = 'keep' for k, fb in enumerate(future): fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 fma10 = fb.get('ma10') or 0 if in_pos: if fl <= stop_cur: legs.append(fc/entry_p - 1) exit_reason = 'stop' in_pos = False break if fma10 > 0 and fc < fma10: legs.append(fc/entry_p - 1) in_pos = False wait = reentry_window exit_reason = 'swing_out' else: wait -= 1 if wait < 0: break prev_high = future[k-1].get('high') or 0 if k > 0 else 0 # 重新站上MA10且当天创新高 → 结构恢复,再进场 if fma10 > 0 and fc > fma10 and fh > prev_high: in_pos = True entry_p = fc stop_cur = fc - atr_val * exit_cfg.get('sl_atr', 1.5) if atr_val > 0 else fc * 0.93 exit_reason = 'swing_re' if in_pos: legs.append((future[-1].get('close') if future else entry_p)/entry_p - 1) total_ret = 1.0 for l in legs: total_ret *= (1 + l) pnl = (total_ret - 1) * 100 exit_price = ep * total_ret hold_days = len(future) if future else 0 elif exit_cfg.get('exit_mode') == 'swing_pivot': # ── 波段·枢轴版:破弱支撑先出,收复弱压且创新高再进 ── reentry_window = exit_cfg.get('reentry_days', 10) legs = [] in_pos = True entry_p = ep stop_cur = stop wait = 0 exit_reason = 'keep' for k, fb in enumerate(future): fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 fws, fwr = fb.get('weak_support') or 0, fb.get('weak_resist') or 0 if in_pos: if fl <= stop_cur: legs.append(fc/entry_p - 1) exit_reason = 'stop' in_pos = False break if fws > 0 and fc < fws: legs.append(fc/entry_p - 1) in_pos = False wait = reentry_window exit_reason = 'swing_out' else: wait -= 1 if wait < 0: break prev_high = future[k-1].get('high') or 0 if k > 0 else 0 if fwr > 0 and fc > fwr and fh > prev_high: in_pos = True entry_p = fc stop_cur = fb.get('weak_support') or (fc - atr_val * exit_cfg.get('sl_atr', 1.5) if atr_val > 0 else fc * 0.93) exit_reason = 'swing_re' if in_pos: legs.append((future[-1].get('close') if future else entry_p)/entry_p - 1) total_ret = 1.0 for l in legs: total_ret *= (1 + l) pnl = (total_ret - 1) * 100 exit_price = ep * total_ret hold_days = len(future) if future else 0 elif exit_cfg.get('exit_mode') == 'swing_ptp': # ── v11.2: MA10真波段 + 弱支撑止损 + 强压减半仓 ── reentry_window = exit_cfg.get('reentry_days', 10) ws0 = last.get('weak_support') or 0 r2_0 = last.get('strong_resist') or 0 stop_cur = ws0 if 0 < ws0 < ep else stop remaining = 1.0 realized_pnl = 0.0 in_pos = True entry_p = ep wait = 0 exit_reason = 'keep' half_done = False for k, fb in enumerate(future): fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 fma10 = fb.get('ma10') or 0 if in_pos: # 强压减半落袋 if r2_0 > 0 and not half_done and fh >= r2_0: realized_pnl += remaining * 0.5 * (r2_0 / entry_p - 1) remaining *= 0.5 half_done = True if fl <= stop_cur: realized_pnl += remaining * (fc / entry_p - 1) remaining = 0 exit_reason = 'stop' in_pos = False break if fma10 > 0 and fc < fma10: realized_pnl += remaining * (fc / entry_p - 1) in_pos = False wait = reentry_window exit_reason = 'swing_out' else: wait -= 1 if wait < 0: break prev_high = future[k-1].get('high') or 0 if k > 0 else 0 if fma10 > 0 and fc > fma10 and fh > prev_high: in_pos = True entry_p = fc stop_cur = fb.get('weak_support') or stop_cur r2_0 = fb.get('strong_resist') or r2_0 half_done = False exit_reason = 'swing_re' if in_pos: realized_pnl += remaining * ((future[-1].get('close') if future else entry_p) / entry_p - 1) pnl = realized_pnl * 100 exit_price = ep * (1 + realized_pnl) hold_days = len(future) if future else 0 else: exit_price = exit_reason = None hold_days = 0 for k, fb in enumerate(future): fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 if target and fh >= target: exit_price, exit_reason, hold_days = target, 'target', k+1 break # 移动止损线:随最高收盘价上移,从不下移 eff_stop = stop if trail_atr and atr_val > 0: highest_close = max(highest_close, fc) eff_stop = max(stop, highest_close - atr_val * trail_atr) if fl <= eff_stop: if trail_atr and eff_stop > stop: exit_price, exit_reason = eff_stop, 'trail' else: exit_price, exit_reason = (eff_stop if trail_atr else fc), 'stop' hold_days = k + 1 break if exit_price is None: exit_price = future[-1].get('close') if future else close exit_reason, hold_days = 'keep', len(future) pnl = (exit_price - ep) / ep * 100 if ep > 0 else 0 trades.append({ 'code': code, 'name': name, 'entry_date': date, 'entry_price': round(close, 2), 'exit_price': round(exit_price, 2), 'profit_pct': round(pnl, 2), 'exit_reason': exit_reason, 'hold_days': hold_days, 'score': total_score, 'score_comp': comp, 'kelly': round(kelly, 3), 'stop_loss': round(stop, 2), 'target': round(target, 2) if target else None, 'dna': _dna, 'factors': {k: (round(v, 3) if isinstance(v, float) else v) for k, v in factors.items()}, }) i += step summary = calc_summary(trades, capital) if summary: # 年化统一用回测区间年数(修复交易聚集导致年化爆炸) _y0 = datetime.strptime(start_date, '%Y-%m-%d') _y1 = datetime.strptime(end_date, '%Y-%m-%d') _bt_years = max((_y1 - _y0).days / 365.0, 0.5) # 信念缩放:优先 conviction 多因子配置,否则默认动量基因×2.5(2026-07-29验证全策略+7~16pp) conv = cfg.get('conviction') if conv: _dna_m = conv.get('dna_mult', 2.0) _sec_t = conv.get('sector_adx_min', 25) _sec_m = conv.get('sector_mult', 2.0) _flo_t = conv.get('flow_delta_min', 0) _flo_m = conv.get('flow_mult', 2.0) _news_m = conv.get('news_mult', 0) # 新闻因子倍数(0=不启用) _news_days = conv.get('news_days', 3) _cap = conv.get('cap') _stack = conv.get('stack', True) for t in trades: _f = t.get('factors') or {} _sa = _f.get('sector_adx') _sec_hit = _sa is not None and _sa > _sec_t _fd = _f.get('flow_delta') _flo_hit = _fd is not None and _fd > _flo_t # 新闻因子(个股+行业) _news_hit = False if _news_m > 0: _code = t.get('code') _date = t.get('entry_date') _sector = _STOCK_SECTOR.get(_code, '') _sn = news_ctx(_code, _date, _news_days) _ssn = sector_news_ctx(_sector, _date, _news_days) if _sector else {'pos': 0, 'neg': 0} _news_hit = (_sn['pos'] + _ssn['pos']) > (_sn['neg'] + _ssn['neg']) and (_sn['pos'] + _ssn['pos']) >= 1 if _stack: b = 1.0 if t.get('dna'): b *= _dna_m if _sec_hit: b *= _sec_m if _flo_hit: b *= _flo_m if _news_hit: b *= _news_m if _cap: b = min(b, _cap) else: b = _dna_m if (t.get('dna') or _sec_hit or _flo_hit or _news_hit) else 1.0 t['boost'] = b summary['conviction_model'] = conv.get('model', '') else: boost_k = cfg.get('exit', {}).get('dna_boost', 2.5) _news_m = cfg.get('news_mult', 0) _news_days = cfg.get('news_days', 3) for t in trades: b = boost_k if t.get('dna') else 1.0 if _news_m > 0: _code = t.get('code') _date = t.get('entry_date') _sector = _STOCK_SECTOR.get(_code, '') _sn = news_ctx(_code, _date, _news_days) _ssn = sector_news_ctx(_sector, _date, _news_days) if _sector else {'pos': 0, 'neg': 0} if (_sn['pos'] + _ssn['pos']) > (_sn['neg'] + _ssn['neg']) and (_sn['pos'] + _ssn['pos']) >= 1: b *= _news_m t['boost'] = b # 集中仓位(该策略最优激进仓位) slots = STRATEGY_SIZING.get(strategy_version, 10) summary['portfolio'] = portfolio_sim(trades, capital, slots) summary['sizing_slots'] = slots # 全参与可行仓位(公平基线:单仓≥5万地板,消除上车运气) summary['portfolio_full'] = portfolio_sim_full(trades, capital) # cagr 统一用回测区间年数覆盖(portfolio_sim里按交易跨度算的会爆炸) _tr = summary['portfolio'].get('total_return_pct', 0) / 100 _tf = summary['portfolio_full'].get('total_return_pct', 0) / 100 summary['portfolio']['cagr_pct'] = round((((1 + _tr) ** (1 / _bt_years)) - 1) * 100, 1) summary['portfolio_full']['cagr_pct'] = round((((1 + _tf) ** (1 / _bt_years)) - 1) * 100, 1) result = { 'strategy': strat['version'], 'strategy_name': strat['name'], 'market': universe, 'period': f"{start_date} ~ {end_date}", 'period_tag': period_tag, 'capital': capital, 'total_stocks_screened': screened, 'scored_events': scored_n, 'trades': trades, 'summary': summary, } if save: save_result(strat, result) return result def max_concurrency(trades): """信号流的最大并发持仓数(用于全参与组合模拟)。按交易日×1.45≈自然日推算退出点。""" from datetime import datetime, timedelta ev = [] for t in trades: try: d0 = datetime.strptime(t['entry_date'], '%Y-%m-%d') d1 = d0 + timedelta(days=max(1, int(t.get('hold_days', 1))) * 1.45) ev.append((d0, 1)) ev.append((d1, -1)) except Exception: pass ev.sort(key=lambda x: (x[0], x[1])) cur = peak = 0 for _, d in ev: cur += d peak = max(peak, cur) return max(peak, 1) MIN_POSITION = 25000 # 单仓可行性下限(A股中低价票2.5万可覆盖;高价票除外,2026-07-29老爸:让v8.1类低信号策略尽量全执行) def portfolio_sim_full(trades, capital=1000000, runs=50): """全参与组合模拟(Monte Carlo 随机选装版): 仓位槽 = min(信号流最大并发, 总资产/单仓下限); 同日竞争时随机洗牌选装,重复 runs 次取均值±方差。 彻底消灭评分挑选偏差(2026-07-29 老爸:v1.0高分信号被偏爱=虚高收益)""" if not trades: return {} natural = max_concurrency(trades) affordable = max(1, int(capital / MIN_POSITION)) slots = min(natural, affordable) # 交易少时直接单跑(无需MC) if len(trades) <= slots * 2: r = portfolio_sim(trades, capital, slots) r.update({'slots': slots, 'natural_concurrency': natural, 'mode': 'full_single', 'std': 0.0, 'p10': r['total_return_pct'], 'p90': r['total_return_pct']}) return r rets = [] finals = [] dds = [] taken = [] for k in range(runs): r = portfolio_sim(trades, capital, slots, random_seed=42 + k) rets.append(r['total_return_pct']) finals.append(r['capital_final']) dds.append(r['portfolio_max_dd_pct']) taken.append(r['positions_taken']) rets_s = sorted(rets) mean_ret = sum(rets) / len(rets) mean_dd = sum(dds) / len(dds) std = (sum((x - mean_ret) ** 2 for x in rets) / len(rets)) ** 0.5 # 年化用实际区间年数(修复硬编码^0.5=2年的虚高问题) _dts = sorted({t['entry_date'] for t in trades if t.get('entry_date')}) if _dts: from datetime import datetime as _dt2 _span_days = (_dt2.strptime(_dts[-1], '%Y-%m-%d') - _dt2.strptime(_dts[0], '%Y-%m-%d')).days _years = max(_span_days / 365.0, 0.5) else: _years = 2.0 return { 'capital_final': round(sum(finals) / len(finals), 0), 'total_return_pct': round(mean_ret, 1), 'cagr_pct': round((((1 + mean_ret / 100) ** (1 / _years)) - 1) * 100, 1), 'portfolio_max_dd_pct': round(mean_dd, 1), 'positions_taken': round(sum(taken) / len(taken)), 'positions_skipped': len(trades) - round(sum(taken) / len(taken)), 'slots': slots, 'natural_concurrency': natural, 'min_position': MIN_POSITION, 'mode': 'full_mc', 'mc_runs': runs, 'std': round(std, 1), 'p10': round(rets_s[int(len(rets_s) * 0.1)], 1), 'p90': round(rets_s[int(len(rets_s) * 0.9)], 1), } COST_RATE = 0.002 # 往返交易费用率(佣金+印花税+滑点≈0.2%) def _trade_legs(t): """波段类出场按2次往返计费""" return 2 if t.get('exit_reason') in ('swing_re', 'swing_ptp') else 1 def portfolio_sim(trades, capital=1000000, max_positions=10, cost=True, random_seed=None): """组合级模拟:固定等分仓位,按交易日历执行,返回最终资产/总收益/资产曲线回撤 规则:每日先结算到期仓位 → 再执行当日入场(仓位满跳过)→ 持仓按成本估值 含交易费用:每笔往返扣 COST_RATE(2026-07-29 老爸:高频策略必须上费用天平)""" if not trades: return {} # 交易日历(用大盘指数日期) # 日历从 stock_daily 实际交易日生成(覆盖策略完整区间,不被全局_MKT_CTX的2年限制截断) _dates = sorted({t['entry_date'] for t in trades if t.get('entry_date')}) if not _dates: return {} _min_d, _max_d = _dates[0], _dates[-1] # 日历缓存(避免每策略x50次MC重复扫stock_daily) global _CAL_CACHE if '_CAL_CACHE' not in globals() or _CAL_CACHE.get('range') != (_min_d, _max_d): _c = sqlite3.connect(DB_PATH) _CAL_CACHE = {'range': (_min_d, _max_d), 'cal': [r[0] for r in _c.execute( "SELECT DISTINCT date FROM stock_daily WHERE date>=? AND date<=? ORDER BY date", (_min_d, _max_d)).fetchall()]} _c.close() cal = _CAL_CACHE['cal'] if not cal: cal = _dates cal_idx = {d: i for i, d in enumerate(cal)} def add_days(d, n): i = cal_idx.get(d) if i is None: return d return cal[min(i + n, len(cal) - 1)] # 按入场日组织 # random_seed 设置时:同日信号随机洗牌(消灭评分挑选偏差, Monte Carlo用) # 否则:同日高分优先(原行为, 集中仓位模拟用) entries = {} for t in trades: entries.setdefault(t['entry_date'], []).append(t) if random_seed is not None: import random as _rnd rng = _rnd.Random(random_seed) for d in entries: rng.shuffle(entries[d]) else: for d in entries: entries[d].sort(key=lambda x: -x.get('score', 0)) cash = capital open_pos = [] # {'exit_date','alloc','pnl'} skipped = 0 peak = capital max_dd = 0 for day in cal: # 结算到期 still = [] for p in open_pos: if p['exit_date'] <= day: cash += p['alloc'] * (1 + p['pnl'] / 100) if cost: cash -= p['alloc'] * COST_RATE * _trade_legs(p.get('trade', {})) else: still.append(p) open_pos = still # 当日入场 for t in entries.get(day, []): if len(open_pos) >= max_positions: skipped += 1 continue equity = cash + sum(p['alloc'] for p in open_pos) boost = t.get('boost', 1.0) alloc = min(equity / max_positions * boost, cash) if alloc <= 0: skipped += 1 continue cash -= alloc open_pos.append({ 'exit_date': add_days(t['entry_date'], t['hold_days']), 'alloc': alloc, 'pnl': t['profit_pct'], 'trade': t, }) equity = cash + sum(p['alloc'] for p in open_pos) peak = max(peak, equity) max_dd = max(max_dd, (peak - equity) / peak * 100) # 期末结算全部 final = cash + sum(p['alloc'] * (1 + p['pnl'] / 100 - (COST_RATE * _trade_legs(p.get('trade', {})) if cost else 0)) for p in open_pos) total_ret = (final - capital) / capital * 100 # 年化 days = len(cal) cagr = ((final / capital) ** (250 / days) - 1) * 100 if days > 0 and final > 0 else 0 total_cost = capital - final + sum(1 for _ in []) # placeholder return { 'capital_final': round(final, 0), 'total_return_pct': round(total_ret, 1), 'cagr_pct': round(cagr, 1), 'portfolio_max_dd_pct': round(max_dd, 1), 'positions_taken': len(trades) - skipped, 'positions_skipped': skipped, } def calc_summary(trades, capital): if not trades: return {} profits = [t['profit_pct'] for t in trades] wins = [t for t in trades if t['profit_pct'] > 0] losses = [t for t in trades if t['profit_pct'] <= 0] win_rate = len(wins) / len(trades) * 100 avg_p = sum(profits) / len(profits) avg_w = sum(t['profit_pct'] for t in wins) / len(wins) if wins else 0 avg_l = sum(t['profit_pct'] for t in losses) / len(losses) if losses else 0 mean_r = avg_p / 100 std_r = math.sqrt(sum((p/100 - mean_r)**2 for p in profits) / (len(profits)-1)) if len(profits) > 1 else 0 sharpe = mean_r / std_r * math.sqrt(252) if std_r > 0 else 0 curve = [capital] for t in trades: curve.append(curve[-1] * (1 + t['profit_pct']/100)) peak = capital max_dd = 0 for c in curve: peak = max(peak, c) max_dd = max(max_dd, (peak - c) / peak * 100) # 普适性:信号月份分布(分散度越高越普适) from collections import Counter months = Counter(t['entry_date'][:7] for t in trades if t.get('entry_date')) n_months = len(months) peak_pct = round(max(months.values()) / len(trades) * 100) if trades else 0 # 香农熵归一化 0-100(分布越均匀越高) entropy = 0.0 if n_months > 1: for c in months.values(): p = c / len(trades) entropy -= p * math.log(p) entropy = entropy / math.log(n_months) * 100 universality = { 'months': n_months, 'peak_pct': peak_pct, 'score': round(entropy, 0), } return { 'total_trades': len(trades), 'win_rate': round(win_rate, 1), 'avg_profit_pct': round(avg_p, 2), 'avg_win_pct': round(avg_w, 2), 'avg_loss_pct': round(avg_l, 2), 'avg_hold_days': round(sum(t['hold_days'] for t in trades) / len(trades), 1), 'sharpe_ratio': round(sharpe, 2), 'max_drawdown_pct': round(max_dd, 2), 'profit_factor': round(abs(avg_w/avg_l), 2) if avg_l != 0 else None, 'wins': len(wins), 'losses': len(losses), 'capital_end': round(curve[-1], 2), 'universality': universality, } # ══════════════════════════════════════════════════════ # 因子归因分析(连续分桶 + 布尔分组) # ══════════════════════════════════════════════════════ ANALYZE_FACTORS = ['rsi', 'adx', 'macd_hist', 'roc', 'atr_pct', 'dist_ma20', 'vol_ratio', 'ma20_slope', 'macd_hist_delta', 'rsi_delta', 'mkt_slope', 'mkt_roc', 'sector_change', 'sector_rank_pct', 'sector_slope', 'flow_pct', 'flow_5d', 'flow_delta', 'weekly_dist', 'score'] BOOL_FACTORS = ['trend_aligned', 'hh_structure', 'hl_structure', 'adx_rising', 'mkt_above_ma20', 'near_high_20d', 'sector_above_ma20', 'weekly_up', 'weekly_aligned'] def analyze_trades(strategy_version): conn = sqlite3.connect(DB_PATH) row = conn.execute( "SELECT results_json FROM strategy_research WHERE version=? ORDER BY id DESC LIMIT 1", (strategy_version,)).fetchone() conn.close() if not row: return {'error': f'无 {strategy_version} 的回测结果,请先运行回测'} result = json.loads(row[0]) return analyze_trade_list(result.get('trades', []), strategy_version) def analyze_trade_list(trades, label=''): if not trades: return {'error': '无交易数据'} wins = [t for t in trades if t['profit_pct'] > 0] report = { 'label': label, 'total': len(trades), 'wins': len(wins), 'losses': len(trades) - len(wins), 'factors': {}, 'bool_factors': {}, 'exit_reasons': {}, 'hold_analysis': {}, 'insights': [], } # 连续因子: 五分桶胜率 for f in ANALYZE_FACTORS: pairs = [(t['factors'].get(f), t['profit_pct'] > 0) for t in trades if t.get('factors', {}).get(f) is not None] if len(pairs) < 30: continue vals = sorted(pairs, key=lambda x: x[0]) w_vals = [v for v, w in pairs if w] l_vals = [v for v, w in pairs if not w] buckets = [] n = len(vals) for bi in range(5): seg = vals[int(n*bi/5):int(n*(bi+1)/5)] if seg: wr = sum(1 for _, w in seg if w) / len(seg) * 100 buckets.append({'range': f"{seg[0][0]:.2f}~{seg[-1][0]:.2f}", 'win_rate': round(wr, 1), 'count': len(seg)}) report['factors'][f] = { 'winner_mean': round(sum(w_vals)/len(w_vals), 3) if w_vals else None, 'loser_mean': round(sum(l_vals)/len(l_vals), 3) if l_vals else None, 'buckets': buckets, } # 布尔因子: True/False 分组胜率 for f in BOOL_FACTORS: pairs = [(t['factors'].get(f), t['profit_pct'] > 0) for t in trades if t.get('factors', {}).get(f) is not None] if len(pairs) < 30: continue t_grp = [w for v, w in pairs if v] f_grp = [w for v, w in pairs if not v] if t_grp and f_grp: report['bool_factors'][f] = { 'true_win_rate': round(sum(t_grp)/len(t_grp)*100, 1), 'true_count': len(t_grp), 'false_win_rate': round(sum(f_grp)/len(f_grp)*100, 1), 'false_count': len(f_grp), } # 出场方式 for t in trades: r = t['exit_reason'] report['exit_reasons'].setdefault(r, {'count': 0, 'total_pnl': 0, 'avg_hold': 0}) d = report['exit_reasons'][r] d['count'] += 1 d['total_pnl'] += t['profit_pct'] d['avg_hold'] += t['hold_days'] for r, d in report['exit_reasons'].items(): d['avg_pnl'] = round(d['total_pnl'] / d['count'], 2) d['avg_hold'] = round(d['avg_hold'] / d['count'], 1) d['total_pnl'] = round(d['total_pnl'], 1) # 持仓天数 hold_buckets = {} for t in trades: hb = '1-3天' if t['hold_days'] <= 3 else ('4-7天' if t['hold_days'] <= 7 else ('8-14天' if t['hold_days'] <= 14 else '15天+')) hold_buckets.setdefault(hb, {'count': 0, 'wins': 0}) hold_buckets[hb]['count'] += 1 if t['profit_pct'] > 0: hold_buckets[hb]['wins'] += 1 for hb, d in hold_buckets.items(): d['win_rate'] = round(d['wins'] / d['count'] * 100, 1) report['hold_analysis'] = hold_buckets # 自动洞察 ins = [] for f, d in report['factors'].items(): if len(d['buckets']) >= 4: wrs = [b['win_rate'] for b in d['buckets']] spread = max(wrs) - min(wrs) if spread >= 12: best = d['buckets'][wrs.index(max(wrs))] worst = d['buckets'][wrs.index(min(wrs))] ins.append(f"📌 {f} 区分度{spread:.0f}pp: [{best['range']}]胜率{best['win_rate']}% vs [{worst['range']}]胜率{worst['win_rate']}%") for f, d in report['bool_factors'].items(): diff = d['true_win_rate'] - d['false_win_rate'] if abs(diff) >= 8: arrow = '✅' if diff > 0 else '❌' ins.append(f"{arrow} {f}=True 胜率{d['true_win_rate']}% vs False {d['false_win_rate']}% (差{abs(diff):.0f}pp)") er = report['exit_reasons'] if 'stop' in er and er['stop']['count'] > er.get('target', {}).get('count', 0) * 2: ins.append(f"⚠️ 止损({er['stop']['count']})远多于止盈({er.get('target',{}).get('count',0)}): 入场追高或止损过紧") report['insights'] = ins return report # ══════════════════════════════════════════════════════ # 持久化 # ══════════════════════════════════════════════════════ def init_table(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS strategy_research ( id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT, name TEXT, summary TEXT, hypothesis TEXT, parent TEXT, config_json TEXT, results_json TEXT, analysis_json TEXT, period TEXT, created_at TEXT, market TEXT DEFAULT 'all' ) """) # 兼容老表加 market 列 try: conn.execute("ALTER TABLE strategy_research ADD COLUMN market TEXT DEFAULT 'all'") except sqlite3.OperationalError: pass try: conn.execute("ALTER TABLE strategy_research ADD COLUMN period_tag TEXT DEFAULT '2y'") except sqlite3.OperationalError: pass conn.commit() conn.close() def save_result(strat, result): init_table() conn = sqlite3.connect(DB_PATH) conn.execute(""" INSERT INTO strategy_research (version, name, summary, hypothesis, parent, config_json, results_json, period, created_at, market, period_tag) VALUES (?,?,?,?,?,?,?,?,?,?,?) """, (strat['version'], strat['name'], strat['summary'], strat['hypothesis'], strat.get('parent'), json.dumps(strat['config'], ensure_ascii=False), json.dumps(result, ensure_ascii=False), result['period'], datetime.now().strftime('%Y-%m-%d %H:%M:%S'), result.get('market', 'all'), result.get('period_tag', '2y'))) conn.commit() conn.close() def save_analysis(version, analysis): init_table() conn = sqlite3.connect(DB_PATH) conn.execute(""" UPDATE strategy_research SET analysis_json=? WHERE id = (SELECT id FROM strategy_research WHERE version=? ORDER BY id DESC LIMIT 1) """, (json.dumps(analysis, ensure_ascii=False), version)) conn.commit() conn.close() def list_strategies(period_tag=None): init_table() conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row if period_tag: # 指定区间:每个 (version, market) 取该区间最新一条 rows = conn.execute(""" SELECT sr.* FROM strategy_research sr INNER JOIN (SELECT version, COALESCE(market,'all') as mkt, MAX(id) as max_id FROM strategy_research WHERE COALESCE(period_tag,'2y')=? GROUP BY version, mkt) latest ON sr.id = latest.max_id ORDER BY sr.version """, (period_tag,)).fetchall() else: # 默认:每个 (version, market) 组合取最新一条 rows = conn.execute(""" SELECT sr.* FROM strategy_research sr INNER JOIN (SELECT version, COALESCE(market,'all') as mkt, MAX(id) as max_id FROM strategy_research GROUP BY version, COALESCE(market,'all')) latest ON sr.id = latest.max_id ORDER BY sr.version """).fetchall() conn.close() out = [] for r in rows: d = dict(r) res = json.loads(d['results_json']) if d.get('results_json') else {} ana = json.loads(d['analysis_json']) if d.get('analysis_json') else None d['summary_stats'] = res.get('summary', {}) d['insights'] = (ana or {}).get('insights', []) d['trades_count'] = len(res.get('trades', [])) d['market'] = d.get('market') or res.get('market') or 'all' del d['results_json'] del d['analysis_json'] out.append(d) existing = {(d['version'], d['market']) for d in out} for v, s in STRATEGIES.items(): if (v, 'all') not in existing and not any(d['version'] == v for d in out): out.append({ 'version': v, 'name': s['name'], 'summary': s['summary'], 'hypothesis': s['hypothesis'], 'parent': s.get('parent'), 'config_json': json.dumps(s['config'], ensure_ascii=False), 'summary_stats': {}, 'insights': [], 'created_at': s.get('created'), 'market': 'all', }) out.sort(key=lambda x: (x['version'], x.get('market', 'all'))) return out if __name__ == '__main__': import sys ver = sys.argv[1] if len(sys.argv) > 1 else 'v3.0' end = '2026-07-24' start = '2026-01-21' r = run_backtest(ver, start, end) print(json.dumps(r['summary'], indent=2, ensure_ascii=False)) a = analyze_trade_list(r['trades'], ver) save_analysis(ver, a) print(json.dumps(a.get('insights', []), indent=2, ensure_ascii=False)) # ══════════════════════════════════════════════════════ # 均值回复引擎 v_mr(B类策略:独立于趋势评分体系) # 2026-08-02 新建。背景:翻倍股分析发现 77% 翻倍股诞生于震荡/中性市, # 共性因子=小盘+MA60下方深跌+近期跌+RSI偏低+低动量(均值回复型)。 # 趋势引擎(v7.1评分体系)无法改造为均值回复——评分体系是趋势导向的, # 此处单独建一套入场逻辑,按市场周期与 v_next4 趋势策略分工。 # ══════════════════════════════════════════════════════ def run_mr_backtest(strategy_version, start_date, end_date, capital=913000, save=True, universe='all', period_tag='10y'): strat = get_strategy(strategy_version) cfg = strat['config'] entry_cfg, exit_cfg = cfg['entry'], cfg['exit'] mr = entry_cfg.get('mr', {}) fetch_start = (datetime.strptime(start_date, '%Y-%m-%d') - timedelta(days=200)).strftime('%Y-%m-%d') prepare_market_context(fetch_start, end_date) prepare_sector_context(start_date, end_date) prepare_flow_context(fetch_start, end_date) prepare_weekly_context(fetch_start, end_date) prepare_news_context(start_date, end_date) conn = sqlite3.connect(DB_PATH) stocks = conn.execute(""" SELECT DISTINCT sd.code, COALESCE(s.name, sd.code) as name FROM stock_daily sd LEFT JOIN stocks s ON sd.code = s.code WHERE sd.date>=? AND sd.date<=? """, (start_date, end_date)).fetchall() conn.close() if universe == 'hk': stocks = [(c, n) for c, n in stocks if is_hk_code(c)] elif universe == 'a': stocks = [(c, n) for c, n in stocks if not is_hk_code(c)] trades = [] screened = 0 skip_stats = {'no_bars': 0, 'bias': 0, 'rsi': 0, 'ret60': 0, 'mom20': 0, 'amount': 0, 'rsi_delta': 0, 'mkt': 0, 'next_open': 0} for code, name in stocks: screened += 1 bars = _bars(code, fetch_start, end_date) if not bars or len(bars) < 70: skip_stats['no_bars'] += 1 continue i = 60 while i < len(bars): if bars[i].get('date', '') < start_date: i += 1 continue b = bars[i] close = b.get('close') or 0 ma60 = b.get('ma60') or 0 rsi = b.get('rsi') if close <= 0 or ma60 <= 0 or rsi is None: i += 1 continue # 1. MA60 下方超跌(单边) bias60 = (close - ma60) / ma60 * 100 bmin = mr.get('bias_min') bmax = mr.get('bias_max') if bmin is not None and bias60 < bmin: skip_stats['bias'] += 1; i += 1; continue if bmax is not None and bias60 > bmax: skip_stats['bias'] += 1; i += 1; continue # 2. RSI 超卖 if rsi > mr.get('rsi_max', 50): skip_stats['rsi'] += 1; i += 1; continue # 3. 近期下跌(单边) if i >= 60: prev60 = bars[i-60].get('close') or 0 prev_ret60 = (close - prev60) / prev60 * 100 if prev60 > 0 else 0 else: prev_ret60 = 0 rmin = mr.get('ret_min') rmax = mr.get('ret_max') if rmin is not None and prev_ret60 < rmin: skip_stats['ret60'] += 1; i += 1; continue if rmax is not None and prev_ret60 > rmax: skip_stats['ret60'] += 1; i += 1; continue # 4. 低动量 prev20 = bars[i-20].get('close') or 0 mom20 = (close - prev20) / prev20 * 100 if prev20 > 0 else 0 if mom20 > mr.get('mom20_max', 5): skip_stats['mom20'] += 1; i += 1; continue # 5. 小盘(20日均成交额,百万元) amt20 = [x.get('amount') or 0 for x in bars[max(0, i-19):i+1]] amt_valid = [a for a in amt20 if a > 0] amount_ma20 = sum(amt_valid) / len(amt_valid) if amt_valid else 0 amount_ma20_m = amount_ma20 / 1000.0 # 千元→百万元 if mr.get('amount_max') is not None and amount_ma20_m > mr['amount_max']: skip_stats['amount'] += 1; i += 1; continue # 6. 止跌回升确认(RSI 5日回升) if i >= 5: rsi0 = bars[i-5].get('rsi') rsi_delta = (rsi - rsi0) if rsi0 is not None else 0 else: rsi_delta = 0 if rsi_delta < mr.get('rsi_delta_min', -2): skip_stats['rsi_delta'] += 1; i += 1; continue # 7. 大盘状态 date = b.get('date') mk = mkt_ctx(date, code) mkt_adx = mk.get('adx') mkt_above = mk.get('above_ma20') mkt_mode = mr.get('mkt_mode', 'any') if mkt_mode == 'sideways': if mkt_adx is not None and mkt_adx >= 25 and mkt_above: skip_stats['mkt'] += 1; i += 1; continue elif mkt_mode == 'bear': if mkt_above is True: skip_stats['mkt'] += 1; i += 1; continue # 次日开盘入场 if i + 1 >= len(bars): skip_stats['next_open'] += 1; i += 1; continue ep = bars[i+1].get('open') or close if ep <= 0: skip_stats['next_open'] += 1; i += 1; continue # ── 出场(均值回归:让利润跑向止盈,无 MA20 截断)── tp_pct = exit_cfg.get('tp_pct', 0.18) sl_pct = exit_cfg.get('sl_pct', 0.08) max_hold = exit_cfg.get('max_hold_days', 25) target = ep * (1 + tp_pct) stop = ep * (1 - sl_pct) future = bars[i+1:i+1+max_hold] exit_price = exit_reason = None hold_days = 0 for k, fb in enumerate(future): fh, fl, fc = fb.get('high') or 0, fb.get('low') or 0, fb.get('close') or 0 if fl <= stop: exit_price, exit_reason, hold_days = stop, 'stop', k+1 break if fh >= target: exit_price, exit_reason, hold_days = target, 'target', k+1 break if exit_price is None: exit_price = future[-1].get('close') if future else ep exit_reason, hold_days = 'time', len(future) pnl = (exit_price - ep) / ep * 100 if ep > 0 else 0 factors = calc_factors(bars, i) factors.update({ 'bias60': round(bias60, 2), 'prev_ret60': round(prev_ret60, 2), 'mom20': round(mom20, 2), 'amount_ma20': round(amount_ma20_m, 2), 'mkt_adx': mkt_adx, 'mkt_above_ma20': mkt_above, 'rsi_delta': round(rsi_delta, 2), }) sc_ctx = sector_ctx(code, date) factors['sector_change'] = sc_ctx.get('change') factors['sector_rank_pct'] = sc_ctx.get('rank_pct') factors['sector_adx'] = sc_ctx.get('adx') factors['sector_above_ma20'] = sc_ctx.get('above_ma20') factors['sector_slope'] = sc_ctx.get('slope') trades.append({ 'code': code, 'name': name, 'entry_date': date, 'entry_price': round(ep, 2), 'exit_price': round(exit_price, 2), 'profit_pct': round(pnl, 2), 'exit_reason': exit_reason, 'hold_days': hold_days, 'score': 0, 'score_comp': {}, 'kelly': 0, 'stop_loss': round(stop, 2), 'target': round(target, 2), 'dna': False, 'factors': {k: (round(v, 3) if isinstance(v, float) else v) for k, v in factors.items()}, }) i += 1 print(f"skip_stats: {skip_stats}") summary = calc_summary(trades, capital) if summary: _y0 = datetime.strptime(start_date, '%Y-%m-%d') _y1 = datetime.strptime(end_date, '%Y-%m-%d') _bt_years = max((_y1 - _y0).days / 365.0, 0.5) slots = STRATEGY_SIZING.get(strategy_version, 5) for t in trades: t['boost'] = 1.0 summary['portfolio'] = portfolio_sim(trades, capital, slots) summary['sizing_slots'] = slots summary['portfolio_full'] = portfolio_sim_full(trades, capital) _tr = summary['portfolio'].get('total_return_pct', 0) / 100 _tf = summary['portfolio_full'].get('total_return_pct', 0) / 100 summary['portfolio']['cagr_pct'] = round((((1 + _tr) ** (1 / _bt_years)) - 1) * 100, 1) summary['portfolio_full']['cagr_pct'] = round((((1 + _tf) ** (1 / _bt_years)) - 1) * 100, 1) result = { 'strategy': strat['version'], 'strategy_name': strat['name'], 'market': universe, 'period': f"{start_date} ~ {end_date}", 'period_tag': period_tag, 'capital': capital, 'total_stocks_screened': screened, 'scored_events': len(trades), 'trades': trades, 'summary': summary, } if save: save_result(strat, result) return result def register_mr_strategy(version, name, summary, hypothesis, mr_cfg, exit_cfg, slots=5, parent='v_mr_base'): """注册均值回复策略(B类):独立 config 结构,不进 STRATEGIES 的 v7.1 评分体系""" STRATEGIES[version] = { 'version': version, 'name': name, 'summary': summary, 'hypothesis': hypothesis, 'parent': parent, 'created': '2026-08-02', 'config': { 'entry': {'min_score': 0, 'min_momentum': 0, 'filters': {}, 'mr': mr_cfg}, 'exit': exit_cfg, 'sizing': {'kelly': False}, 'eval_step': 1, }, } STRATEGY_SIZING[version] = slots return STRATEGIES[version]