Files
MoFin/strategy_lab.py
T

2470 lines
131 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
from mo_models import is_hk_stock
# 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 + 动量≥810%止盈 / 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×ATRRR→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结构+15ppROC甜区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-08-11 超跌策略注册(v_weak/p_oversold 回测支持)──
"v_weak": {
"version": "v_weak",
"name": "弱市超跌确认(ADX甜区+6条件)",
"summary": "大盘MA20下+ADX[25,30]甜区 + bias60[-35,-20] + RSI<=25 + 5日急跌 + 距低点近 + 收阳缩量",
"hypothesis": "弱市深超跌反弹,六步方法论+12维框架定稿",
"parent": "v_mr",
"config": {
"entry": {"min_score": 0, "min_momentum": 0, "filters": {}, "mr": {
# 2026-08-12: 采用 v_mr v2 研发验证参数(v_mr_strategy.md §1010y 21690笔/年化15.6%
# 单边 bias(只设上界,不设下界——最深超跌的票不被挡在外面)
"bias_max": -10, # bias60 比MA60低超10% = 深超跌(单边)
"rsi_max": 42, # RSI 超卖
"ret_max": -15, # 60日跌幅超15%(单边)
"mom20_max": 5, # 20日动量<=5%(低动量,不追已反弹)
"amount_max": 15, # 20日均成交额<=1500万 = 小盘
"rsi_delta_min": 2, # RSI 5日回升>=2 = 止跌确认
"mkt_mode": "any", # 不限大盘(10y验证 sideways 过滤有害)
}},
"exit": {"tp_pct": 0.18, "sl_pct": 0.08, "max_hold_days": 25},
"sizing": {"kelly": False},
"eval_step": 1,
},
},
"v_oversold": {
"version": "v_oversold",
"name": "预测超跌反弹(12维因子)",
"summary": "弱市+小市值+低PE+新闻+行业弱+深跌",
"hypothesis": "由果及因:预测超跌反弹",
"parent": "v_weak",
"config": {
"entry": {"min_score": 0, "min_momentum": 0, "filters": {}, "mr": {
# run_mr_backtest 原生 + 外部因子(mkt_rsi/mkt_dd60/mcap_q/pe_q/news3/sec_ret20
"bias_max": -20, # 深跌
"rsi_max": 50, # 超卖
"ret_max": -10, # 60日跌超10%
"mom20_max": 0, # 低动量
"mkt_mode": "bear", # 弱市
"mkt_rsi_max": 50, # 大盘RSI<50(弱)
"mkt_dd60_max": -5, # 大盘距60日高点回撤>5%
"mcap_q_max": 0.2, # 小市值(全市场后20%分位)
"pe_q_max": 0.2, # 低PE(全市场后20%分位)
"news3_min": 1, # 3日内有新闻
"sec_ret20_max": 0, # 行业20日动量<=0(行业弱)
}},
"exit": {"tp_pct": None, "sl_pct": 0.05, "max_hold_days": 40},
"sizing": {"kelly": False},
"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<v7.1。",
},
"v6.2": {
"title": "资金+板块双滤(v4.0f+flow_5d>-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+三重信念×2DNA+行业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%)。",
},
"v_oversold": {
"title": "预测超跌反弹v1(弱市+小市值+低PE+新闻+行业弱+深跌)",
"algorithm": "入场: mkt_rsi<50 + mcap_q<0.2 + pe_q<0.2 + news3>=1 + sec_ret20<0 + bias60<-20 + mkt_dd60<=-5。阴跌跳过: 连跌>=2+ADX<=55+RSI>=33。出场: 支撑下5%止损/压力位止盈/40日。仓位: 10槽15%+单日限5。",
"rationale": "由果及因(30207大涨段)+12维预测因子扫描(887万样本),预测力随因子叠加单调提升(3.52%→11.61%大涨率)。",
"evidence": "统一资金模拟年化18.57%/回撤-32.4%/胜率~90%(2016-2026)。阴跌中段判定剔除大回撤源头(连跌+弱趋势+RSI偏高时信号avg-6.35%→保留+6.75%)。",
},
"v_weak": {
"title": "v_weak 弱市超跌确认(大盘MA20下+ADX甜区[25,30]+6条件)",
"algorithm": "入场: 大盘MA20下+ADX∈[25,30]甜区 + bias60∈[-35,-20]深超跌 + RSI≤25极度超卖 + 5日急跌≤-3% + 距20日低点<5% + 收阳 + 缩量vol5/vol20<1.0。出场: tp30%/sl12%/40日。",
"rationale": "六步方法论+12维框架定稿(2026-08-03),弱市深超跌均值回复。",
"evidence": "10y全市场2649笔/单笔+10.66%/胜率68.4%/组合5槽+115%/年化8.8%/回撤32%。当前实盘策略(mr_scanner)。",
},
})
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, # 波段MA103仓+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.14仓
'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_next33仓
}
# ══════════════════════════════════════════════════════
# 大盘 / 行业上下文
# ══════════════════════════════════════════════════════
_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
closes = [b.get('close') or 0 for b in bars]
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
# 2026-08-11: mkt_dd60 大盘距60日高点回撤 + mkt_rsip_oversold 门控)
hi60 = max(closes[max(0, i-59):i+1]) if closes else 0
mkt_dd60 = round(((b.get('close') or 0) - hi60) / hi60 * 100, 2) if hi60 > 0 else None
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,趋势市>252026-07-30强化趋势过滤)
'rsi': b.get('rsi'), # 2026-08-11: 大盘RSIp_oversold 门控)
'mkt_dd60': mkt_dd60, # 2026-08-11: 大盘距60日高点回撤
}
return ctx
# ── 2026-08-11 外部因子缓存(基本面/新闻/行业动量,回测超跌策略用)──
# 预加载模式:一次载入内存,避免循环内逐日查库(403万行news/62万行sector全表扫=卡死根因)
_EXTERNAL_CACHE = {}
_EXT_READY = False # prepare_external_context 是否已执行
_EXT_MCAP_Q = {} # code -> mcap 分位 (0~1)
_EXT_PE_Q = {} # code -> pe 分位 (0~1)
_EXT_SECTOR = {} # code -> sector_name
_EXT_SEC_RET20 = {} # code -> 20日行业动量(用最近收盘日)
_EXT_NEWS_DATES = {} # code -> sorted [日期串, ...]
def prepare_external_context():
"""预加载外部因子数据(回测前调用一次):
- stock_fundamentals(5546行) 全量载入算 mcap/pe 分位
- stock_sectors(581行) 载入 code→sector 映射
- sector_index_daily 载入行业20日动量
- stock_news 载入每code新闻日期列表(3日窗口用 bisect 查)
"""
global _EXT_READY, _EXT_MCAP_Q, _EXT_PE_Q, _EXT_SECTOR, _EXT_SEC_RET20, _EXT_NEWS_DATES, _EXTERNAL_CACHE
if _EXT_READY:
return
import sqlite3, bisect
conn = sqlite3.connect(DB_PATH)
try:
# 1. 基本面分位(当前快照全市场排序)
rows = conn.execute("SELECT code, mcap_total, pe FROM stock_fundamentals").fetchall()
mcaps = sorted(r[1] for r in rows if r[1] and r[1] > 0)
pes = sorted(r[2] for r in rows if r[2] and r[2] > 0)
n_mcap, n_pe = len(mcaps), len(pes)
for code, mcap, pe in rows:
if mcap and mcap > 0 and n_mcap:
_EXT_MCAP_Q[code] = round(bisect.bisect_left(mcaps, mcap) / n_mcap, 3)
if pe and pe > 0 and n_pe:
_EXT_PE_Q[code] = round(bisect.bisect_left(pes, pe) / n_pe, 3)
# 2. code→sector 映射
for code, sec in conn.execute("SELECT code, sector FROM stock_sectors_em").fetchall():
_EXT_SECTOR[code] = sec
for code, sec, src in conn.execute("SELECT code, sector_name, source FROM stock_sectors").fetchall():
if code not in _EXT_SECTOR and src in ('ths', 'hk_em', 'hk_manual'):
_EXT_SECTOR[code] = sec
# 3. 行业20日动量:每 sector 取最新收盘 + 20日前收盘
for sec in set(_EXT_SECTOR.values()):
closes = conn.execute(
"SELECT close FROM sector_index_daily WHERE sector=? ORDER BY date DESC LIMIT 21",
(sec,)).fetchall()
if len(closes) >= 20 and closes[-1][0] and closes[-1][0] > 0:
ret20 = round((closes[0][0] - closes[-1][0]) / closes[-1][0] * 100, 2)
for code, s in _EXT_SECTOR.items():
if s == sec:
_EXT_SEC_RET20[code] = ret20
# 4. 新闻日期列表(每 code 的所有新闻日期,3日窗口用 bisect 统计)
for code, d in conn.execute("SELECT code, SUBSTR(date,1,10) FROM stock_news").fetchall():
_EXT_NEWS_DATES.setdefault(code, []).append(d)
for code in _EXT_NEWS_DATES:
_EXT_NEWS_DATES[code] = sorted(_EXT_NEWS_DATES[code])
except sqlite3.OperationalError:
pass
finally:
conn.close()
_EXT_READY = True
_EXTERNAL_CACHE.clear()
def _days_ago(dt, n):
"""返回 dt 前 n 天的日期字符串"""
from datetime import datetime, timedelta
try:
return (datetime.strptime(dt, '%Y-%m-%d') - timedelta(days=n)).strftime('%Y-%m-%d')
except:
return dt
def _get_external_factors(code, dt):
"""纯内存查询:返回该 code 在 dt 日的外部因子(mcap_q/pe_q/news3/sec_ret20"""
key = (code, dt)
if key in _EXTERNAL_CACHE:
return _EXTERNAL_CACHE[key]
result = {}
if _EXT_READY:
# 基本面分位(预计算,O(1)
if code in _EXT_MCAP_Q:
result['mcap_q'] = _EXT_MCAP_Q[code]
if code in _EXT_PE_Q:
result['pe_q'] = _EXT_PE_Q[code]
# 行业20日动量(预计算)
if code in _EXT_SEC_RET20:
result['sec_ret20'] = _EXT_SEC_RET20[code]
# 新闻3日计数(bisect 统计 3日窗口)
if code in _EXT_NEWS_DATES:
import bisect
dates = _EXT_NEWS_DATES[code]
lo = bisect.bisect_right(dates, _days_ago(dt, 3))
hi = bisect.bisect_right(dates, dt)
result['news3'] = hi - lo
_EXTERNAL_CACHE[key] = result
return result
def is_hk_code(code):
"""港股判断:统一委托 mo_models.is_hk_stock5位0/1开头=港股,事实源规则)
规则收敛说明:旧实现只认 5位0开头;mo_models 认 5位0/1开头。
港股回测数据均为 0 开头代码,1 开头 5 位代码不存在于 stock_daily,无实际影响。
"""
return is_hk_stock(code)
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]
# 2026-08-11 性能修复:ADX 全序列只算一次(原代码内层每天重算=O(N²),296板块×490天卡死)
from backtest_framework import calc_trend_strength
_hl = sec_hl.get(sec, [])
_av = None
if len(_hl) >= 20:
_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)
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 = _av[i] if (_av is not None and i < len(_av)) else None
_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 来自 snapshotadx/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
# ── 2026-08-11 超跌/大盘RSI/基本面/新闻因子(v_weak/p_oversold 回测支持)──
if not chk('bias60', filters.get('bias60_min'), filters.get('bias60_max')): return False
if not chk('dist_lo20', filters.get('dist_lo20_min'), filters.get('dist_lo20_max')): return False
if not chk('r5f', filters.get('r5f_min'), filters.get('r5f_max')): return False
if not chk('vol_shrink', filters.get('vol_shrink_min'), filters.get('vol_shrink_max')): return False
if filters.get('require_close_up') and not factors.get('close_up'): return False
# 大盘因子(mkt_rsi/mkt_dd60
if not chk('mkt_rsi', filters.get('mkt_rsi_min'), filters.get('mkt_rsi_max')): return False
if not chk('mkt_dd60', filters.get('mkt_dd60_min'), filters.get('mkt_dd60_max')): return False
# 基本面/新闻因子(mcap_q/pe_q/news3/sec_ret20
if not chk('mcap_q', filters.get('mcap_q_min'), filters.get('mcap_q_max')): return False
if not chk('pe_q', filters.get('pe_q_min'), filters.get('pe_q_max')): return False
if not chk('news3', filters.get('news3_min'), filters.get('news3_max')): return False
if not chk('sec_ret20', filters.get('sec_ret20_min'), filters.get('sec_ret20_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 # 更高的低点 = 上升结构
# ── 2026-08-11 超跌因子(v_weak/p_oversold 回测支持)──
ma60 = b.get('ma60') or 0
f['bias60'] = round((close - ma60) / ma60 * 100, 2) if ma60 > 0 else None
if idx >= 19:
lo20 = min((x.get('low') or 1e9) for x in bars[idx-19:idx+1])
f['dist_lo20'] = round((close - lo20) / lo20 * 100, 2) if lo20 and lo20 > 0 else None
if idx >= 5:
prev5c = bars[idx-5].get('close') or 0
f['r5f'] = round((close - prev5c) / prev5c * 100, 2) if prev5c > 0 else None
if idx >= 19:
vol5 = sum((x.get('volume') or 0) for x in bars[idx-4:idx+1]) / 5
vol20 = sum((x.get('volume') or 0) for x in bars[idx-19:idx+1]) / 20
f['vol_shrink'] = round(vol5 / vol20, 2) if vol20 > 0 else None
f['close_up'] = close > (bars[idx-1].get('close') or 0) if idx >= 1 else None
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)
prepare_external_context() # 2026-08-11: 预加载外部因子(基本面分位/行业动量/新闻),避免循环内查库
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))
# ── 2026-08-11 外部因子注入(超跌策略回测支持)──
factors['mkt_rsi'] = mk.get('rsi')
factors['mkt_dd60'] = mk.get('mkt_dd60')
_lazy = _get_external_factors(code, date)
if _lazy:
factors.update(_lazy)
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()
# ── 2026-08-121m/6m/1y 无独立回测记录时,从最长区间 trades 切时间窗口重算 summary ──
# 研究 Tab 期间筛选按 period_tag 查 DB,但 v_weak/v_oversold 只有 2y/5y/10y——
# 选近1月/6月/1年时查不到记录显示空。改为从最长区间 trades 切窗口算,无需单独回测。
PERIOD_SLICE_DAYS = {'1m': 30, '6m': 185, '1y': 365}
sliced_summaries = {} # version -> (summary, trades_count)
if period_tag in PERIOD_SLICE_DAYS:
days = PERIOD_SLICE_DAYS[period_tag]
have_period = {r['version'] for r in rows}
from datetime import datetime as _dt, timedelta as _td
for v in list(STRATEGIES.keys()) + [x for x in STRATEGY_DESCRIPTIONS.keys() if x not in STRATEGIES]:
if v in have_period:
continue # 已有该 period 记录,不用切
row = conn.execute(
"SELECT results_json FROM strategy_research WHERE version=? ORDER BY "
"CASE COALESCE(period_tag,'2y') WHEN '10y' THEN 3 WHEN '5y' THEN 2 ELSE 1 END DESC, id DESC LIMIT 1",
(v,)).fetchone()
if not row:
continue
try:
res = json.loads(row[0])
except Exception:
continue
trades = res.get('trades', [])
if not trades:
continue
max_date = max(t.get('entry_date', '') for t in trades)
try:
cutoff = (_dt.strptime(max_date, '%Y-%m-%d') - _td(days=days)).strftime('%Y-%m-%d')
except Exception:
continue
sliced = [t for t in trades if t.get('entry_date', '') >= cutoff]
if not sliced:
continue
# 2026-08-12 补充:除 calc_summary 基础指标外,还要算组合模拟 portfolio/portfolio_full——
# 否则执行数(positions_taken)=0、全参与(total_return)=—、综合分 ret 分量=0(老莫发现矛盾)
_summary = calc_summary(sliced, 1000000)
try:
for _t in sliced:
_t.setdefault('boost', 1.0)
_summary['portfolio'] = portfolio_sim(sliced, 1000000, max_positions=10) # 10槽组合
_summary['portfolio_full'] = portfolio_sim(sliced, 1000000, max_positions=100) # 全参与近似
except Exception:
pass
sliced_summaries[v] = (_summary, len(sliced), period_tag)
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):
# 2026-08-121m/6m/1y 优先用切窗口算的 summary(无独立记录时),否则空
_ss, _tc, _pt = sliced_summaries.get(v, ({}, 0, None))
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': _ss, 'insights': [], 'created_at': s.get('created'),
'market': 'all', 'period_tag': _pt or period_tag, 'trades_count': _tc,
})
# 2026-08-11:补充仅在 STRATEGY_DESCRIPTIONS 的策略(如 v_oversold 预测扫描策略,无回测 config)
for v, s in STRATEGY_DESCRIPTIONS.items():
if v not in STRATEGIES and not any(d['version'] == v for d in out):
_ss, _tc, _pt = sliced_summaries.get(v, ({}, 0, None))
out.append({
'version': v, 'name': s.get('title', v), 'summary': s.get('algorithm', ''),
'hypothesis': s.get('rationale', ''), 'parent': None,
'config_json': '{}',
'summary_stats': _ss, 'insights': [], 'created_at': None,
'market': 'all', 'period_tag': _pt or period_tag, 'trades_count': _tc,
})
out.sort(key=lambda x: (x['version'], x.get('market', 'all')))
# 2026-08-11:标记当前实盘/新策略(研究 Tab 拆分当前/历史区域)
# 当前 = 实盘在跑(v_weak) + 新策略待上线(v_oversold)
# v_next4 已移除(池内卫星仓,全市场失效,现有池子票非它选取)
# 其余 = 历史/研究阶段
CURRENT_VERSIONS = {"v_oversold", "v_weak"}
for d in out:
d['current'] = d.get('version') in CURRENT_VERSIONS
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)
prepare_external_context() # 2026-08-11: 预加载外部因子(基本面分位/行业动量/新闻),避免循环内查库
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
# 8. 大盘RSI/回撤 + 外部因子(mcap_q/pe_q/news3/sec_ret202026-08-11 支持 v_oversold
if mr.get('mkt_rsi_max') is not None:
_mkt_rsi = mk.get('rsi')
if _mkt_rsi is None or _mkt_rsi > mr['mkt_rsi_max']:
skip_stats['mkt'] += 1; i += 1; continue
if mr.get('mkt_dd60_max') is not None:
_mkt_dd60 = mk.get('mkt_dd60')
if _mkt_dd60 is None or _mkt_dd60 > mr['mkt_dd60_max']:
skip_stats['mkt'] += 1; i += 1; continue
_ext = _get_external_factors(code, date)
if mr.get('mcap_q_max') is not None:
_mcap_q = _ext.get('mcap_q')
if _mcap_q is None or _mcap_q > mr['mcap_q_max']:
skip_stats['amount'] += 1; i += 1; continue
if mr.get('pe_q_max') is not None:
_pe_q = _ext.get('pe_q')
if _pe_q is None or _pe_q > mr['pe_q_max']:
skip_stats['amount'] += 1; i += 1; continue
if mr.get('news3_min') is not None:
_news3 = _ext.get('news3', 0)
if _news3 < mr['news3_min']:
skip_stats['amount'] += 1; i += 1; continue
if mr.get('sec_ret20_max') is not None:
_sec_ret20 = _ext.get('sec_ret20')
if _sec_ret20 is None or _sec_ret20 > mr['sec_ret20_max']:
skip_stats['amount'] += 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) or 0.18 # None 时用默认 0.18
sl_pct = exit_cfg.get('sl_pct', 0.08) or 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),
})
# 2026-08-11: 外部因子记录(v_oversold 12维因子可分析)
factors['mkt_rsi'] = mk.get('rsi')
factors['mkt_dd60'] = mk.get('mkt_dd60')
factors.update(_ext)
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]