2492 lines
133 KiB
Python
2492 lines
133 KiB
Python
#!/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 + 动量≥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-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 §10,10y 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-08-14 港股归因研发)──
|
||
"hk_mr1": {
|
||
"version": "hk_mr1",
|
||
"name": "港股深度超卖反弹v1(trend_down主战场)",
|
||
"summary": "港股原生MR:深度超卖(RSI<25)+深度超跌(bias60<-15)+中期深跌(ret60<-25)+止跌回升+放量确认",
|
||
"hypothesis": "港股通归因(604只×7.5年):RSI<25胜率55.6%/20日均+3.09%,bias60[-30,-20]+3.01%。港股大盘蓝筹只有极端超卖才反弹,阈值比A股深。温区门控:trend_down温区55%胜率+3.59%,其他温区平庸——只在港股trend_down启用",
|
||
"parent": "v_weak",
|
||
"config": {
|
||
"entry": {"min_score": 0, "min_momentum": 0, "filters": {}, "mr": {
|
||
"bias_max": -15, # 深度超跌(港股稳健区间-15~-20)
|
||
"rsi_max": 25, # 深度超卖(港股归因:RSI<25胜率55.6%)
|
||
"ret_max": -25, # 中期深跌
|
||
"mom20_max": 5, # 低动量
|
||
"amount_max": None, # 不限小盘(港股通大盘蓝筹)
|
||
"rsi_delta_min": 2, # 止跌回升确认
|
||
"mkt_mode": "any", # 温区门控由 strategy_router 按 hk trend_down 负责
|
||
}},
|
||
"exit": {"tp_pct": 0.25, "sl_pct": 0.15, "max_hold_days": 30}, # 港股无涨跌停波动大,放宽止损
|
||
"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+三重信念×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%)。",
|
||
},
|
||
"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, # 波段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
|
||
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_rsi(p_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,趋势市>25,2026-07-30强化趋势过滤)
|
||
'rsi': b.get('rsi'), # 2026-08-11: 大盘RSI(p_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_stock(5位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 来自 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
|
||
# ── 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-12:1m/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-12:1m/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_ret20,2026-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]
|