final_strategy.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. CYB50 T+1 最终策略回测(完整K线管道版)
  5. ──────────────────────────────────────────────────────────────────────
  6. 数据管道:
  7. 原始K线CSV → 技术指标 → 多空信号 → 交易执行 → T+1转换 → 市场环境标注 → 过滤分析
  8. 策略逻辑:
  9. 必要条件:市场状态 NOT IN ['下跌趋势低波', '震荡低波'](死亡区过滤)
  10. 入场条件:加分模型评分 >= 5(Version D 阈值)
  11. 仓位加成:评分≥5 且命中组合规则 → 1.5x 仓位(调整盈亏金额)
  12. 对比版本:
  13. B — 仅排死亡区(基准)
  14. D — 排死亡区 + 评分≥5(入场过滤)
  15. F — 排死亡区 + 评分≥5 + 组合规则加仓(最终策略)
  16. 组合规则(走前向验证,训练2023-2024,验证2025,盲测2026):
  17. 规则1:RSI区域=中性偏弱 & rsi_bin=rsi偏低 (训练67% → 验证80%)
  18. 规则2:vol_bin=vol中 & ts_bin=ts强 (训练57% → 验证60%)
  19. 规则3:波动率水平=中等 & ts_bin=ts强 (训练57% → 验证60%)
  20. 规则4:市场状态=震荡高波 & RSI区域=中性偏弱 (训练57% → 验证50%)
  21. 规则5:RSI区域=中性偏弱 & ts_bin=ts弱 (训练57% → 验证50%)
  22. """
  23. import sys, io, os, contextlib
  24. if sys.platform == 'win32':
  25. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
  26. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
  27. import pandas as pd
  28. import numpy as np
  29. from datetime import datetime
  30. import warnings
  31. warnings.filterwarnings('ignore')
  32. from cyb50_30min_dual_direction import (
  33. IntradayDataFetcher, DualDirectionSignalGenerator, DualDirectionExecutor
  34. )
  35. from t1_converter import simulate_t1_trades
  36. from comfort_zone_analyzer import MarketEnvironmentAnalyzer, ComfortZoneAnalyzer
  37. # ──────────────────────────────────────────────────────────────────
  38. # 配置项
  39. # ──────────────────────────────────────────────────────────────────
  40. DATA_CSV = os.path.join(os.path.dirname(__file__), 'cyb50_30min_2023_to_20260325.csv')
  41. INITIAL = 1_000_000
  42. DEATH_ZONES = {'下跌趋势低波', '震荡低波'}
  43. BOOST_MULT = 1.5 # 命中组合规则时的仓位乘数
  44. USE_FEES = True # True=含手续费(t1_converter计算); False=按价差×数量重算
  45. EXPORT_CSV = True # True=每次运行后导出各版本明细到 CSV(含K线指标)
  46. VERBOSE = False # True=显示策略执行过程详情(大量输出)
  47. SEP = '=' * 72
  48. # ──────────────────────────────────────────────────────────────────
  49. # 工具:压制子模块 stdout
  50. # ──────────────────────────────────────────────────────────────────
  51. @contextlib.contextmanager
  52. def suppress_stdout():
  53. old = sys.stdout
  54. sys.stdout = io.StringIO()
  55. try:
  56. yield
  57. finally:
  58. sys.stdout = old
  59. def ctx():
  60. """每次返回新的上下文管理器(VERBOSE=False时压制输出)"""
  61. return contextlib.nullcontext() if VERBOSE else suppress_stdout()
  62. # ──────────────────────────────────────────────────────────────────
  63. # 指标分箱
  64. # ──────────────────────────────────────────────────────────────────
  65. def vol_bin(v):
  66. if pd.isna(v): return 'unknown'
  67. if v < 0.20: return 'vol极低'
  68. if v < 0.40: return 'vol低'
  69. if v < 0.60: return 'vol中'
  70. if v < 0.80: return 'vol高'
  71. return 'vol极高'
  72. def rsi_bin(v):
  73. if pd.isna(v): return 'unknown'
  74. if v < 0.05: return 'rsi极底'
  75. if v < 0.10: return 'rsi底'
  76. if v < 0.20: return 'rsi低'
  77. if v < 0.40: return 'rsi偏低'
  78. if v < 0.60: return 'rsi中'
  79. if v < 0.80: return 'rsi偏高'
  80. return 'rsi高'
  81. def ts_bin(v):
  82. if pd.isna(v): return 'unknown'
  83. if v < 1.0: return 'ts弱'
  84. if v < 1.5: return 'ts中弱'
  85. if v < 2.5: return 'ts中'
  86. if v < 4.0: return 'ts强'
  87. return 'ts极强'
  88. def add_bins(df):
  89. df = df.copy()
  90. df['vol_bin'] = df['波动率分位'].apply(vol_bin)
  91. df['rsi_bin'] = df['RSI分位'].apply(rsi_bin)
  92. df['ts_bin'] = df['趋势强度'].apply(ts_bin)
  93. return df
  94. # ──────────────────────────────────────────────────────────────────
  95. # Version G: 日线指标 & 区域净分
  96. # ──────────────────────────────────────────────────────────────────
  97. def build_daily_indicators(data_df: pd.DataFrame) -> pd.DataFrame:
  98. """从30分钟K线聚合日线,计算日线技术指标"""
  99. d = data_df.resample('D').agg(
  100. Open=('Open', 'first'), High=('High', 'max'),
  101. Low=('Low', 'min'), Close=('Close', 'last'),
  102. Volume=('Volume', 'sum')
  103. ).dropna(subset=['Close'])
  104. delta = d['Close'].diff()
  105. gain = delta.where(delta > 0, 0).rolling(14).mean()
  106. loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
  107. d['RSI_D'] = 100 - 100 / (1 + gain / (loss + 1e-9))
  108. low9 = d['Low'].rolling(9).min()
  109. high9 = d['High'].rolling(9).max()
  110. rsv = (d['Close'] - low9) / (high9 - low9 + 1e-9) * 100
  111. d['K_D'] = rsv.ewm(com=2, adjust=False).mean()
  112. d['D_D'] = d['K_D'].ewm(com=2, adjust=False).mean()
  113. d['J_D'] = 3 * d['K_D'] - 2 * d['D_D']
  114. d['MA5_D'] = d['Close'].rolling(5).mean()
  115. d['MA20_D'] = d['Close'].rolling(20).mean()
  116. d['MA5_slope'] = d['MA5_D'].diff() / d['MA5_D'].shift() * 100 # 日MA5斜率%
  117. d['pct_MA20'] = (d['Close'] - d['MA20_D']) / d['MA20_D'] * 100
  118. bm = d['Close'].rolling(20).mean()
  119. bs = d['Close'].rolling(20).std()
  120. d['BB_pos_D'] = (d['Close'] - (bm - 2*bs)) / (4*bs + 1e-9)
  121. d['Mom5_D'] = d['Close'].pct_change(5) * 100
  122. d['Mom10_D'] = d['Close'].pct_change(10) * 100
  123. return d
  124. def attach_zone_indicators(enriched: pd.DataFrame,
  125. data_with_ind: pd.DataFrame,
  126. daily: pd.DataFrame) -> pd.DataFrame:
  127. """附加入场时刻的30分钟K线指标 + 当日日线指标"""
  128. kline_cols = ['RSI', 'K', 'D', 'J', 'MACD_hist',
  129. 'BB_width', 'ATR_Pct', 'Momentum', 'Volume_Ratio']
  130. daily_cols = ['RSI_D', 'K_D', 'J_D', 'MA5_slope',
  131. 'pct_MA20', 'BB_pos_D', 'Mom5_D', 'Mom10_D']
  132. out = enriched.copy()
  133. # 30分钟指标 — 向量化
  134. for c in kline_cols:
  135. if c in data_with_ind.columns:
  136. out[c] = out['开仓时间'].map(
  137. lambda t, col=c: data_with_ind.at[t, col]
  138. if t in data_with_ind.index else float('nan'))
  139. # 日线指标 — 向量化
  140. entry_days = out['开仓时间'].dt.normalize()
  141. for c in daily_cols:
  142. if c in daily.columns:
  143. out[c] = entry_days.map(
  144. lambda t, col=c: daily.at[t, col]
  145. if t in daily.index else float('nan'))
  146. return out
  147. # 舒适区规则(命中 +1 分)— 来自 kline_zone_analysis 分析结论
  148. COMFORT_RULES = [
  149. # (col1, lo1, hi1, col2, lo2, hi2, 描述)
  150. ('J', 20, 50, 'J_D', 20, 50, 'J跨周期共振低位'), # WR 85.7%
  151. ('RSI', 45, 55, 'RSI_D', 60, 70, 'RSI 30min温和+日线强'), # WR 80.0%
  152. ('K', 20, 40, 'K_D', 80, 999, 'K低位+日线K超买(反弹)'), # WR 71.4%
  153. ('K', 20, 40, 'K_D', 20, 40, 'K跨周期共振低位'), # WR 62.5%
  154. ('J', 0, 20, 'J_D', 20, 50, 'J超卖+日线低位'), # WR 66.7%
  155. ('RSI', 25, 35, 'MA5_slope', 0.1, 0.5, 'RSI弱+日MA5上升'), # WR 66.7%
  156. ('RSI', 35, 45, 'RSI_D', 40, 50, 'RSI双偏弱共振'), # WR 66.7%
  157. ('RSI', 35, 45, 'MA5_slope', 0.1, 0.5, 'RSI偏弱+MA5上升'), # WR 66.7%
  158. ('RSI_D', 60, 70, 'Mom5_D', -2, 0, '日RSI强+小幅回调中'), # WR 63.6%
  159. ('BB_pos_D',0.3,0.5, 'J_D', 0, 20, 'BB中下+日KDJ超卖'), # WR 62.5%
  160. ('MACD_hist',0.8,999, None, None, None, 'MACD强多头'), # WR 66.7%
  161. ('Momentum', 0, 0.01, None, None, None, '30min小涨'), # WR 62.5%
  162. ]
  163. # 黑暗区规则(命中 -1 分)
  164. DARK_RULES = [
  165. ('RSI', 45, 55, 'RSI_D', 50, 60, 'RSI双均衡无方向'), # WR 25%
  166. ('Momentum',-999,-0.03,'Mom5_D', -999, -4, '动量双大跌共振'), # WR 20%
  167. ('Volume_Ratio',1.8,999,'ATR_Pct', -999,0.006, '大放量+极低波动'), # WR 28.6%
  168. ('MACD_hist', 0.2, 0.8, None, None, None, 'MACD弱多头'), # WR 25%
  169. ('Volume_Ratio',-999,0.5,None,None, None, '极度缩量'), # WR 25%
  170. ('K', 20, 40, 'K_D', 60, 80, 'K低位+日线K高位'), # WR 33.3%
  171. ('RSI', 35, 45, 'MA5_slope', 0.5, 999, 'RSI偏弱+急速上升'), # WR 25%
  172. ('RSI', 35, 45, 'RSI_D', 70, 999, 'RSI偏弱+日线超买'), # WR 25%
  173. ('Momentum',-999,-0.03, None, None, None, '30min大跌'), # WR 26.7%
  174. ('RSI', 25, 35, 'MA5_slope', -999,-0.5, 'RSI弱+日MA5急降'), # WR 26.3%
  175. ('BB_pos_D',-999, 0.1, 'J_D', 0, 20, 'BB超下轨+日KDJ超卖'), # WR 28.6%
  176. ('Mom10_D', 6, 999, None, None, None, '日线10日大涨高位'), # WR 32.1%
  177. ]
  178. def _in_range(val, lo, hi) -> bool:
  179. v = pd.to_numeric(val, errors='coerce')
  180. return not pd.isna(v) and lo <= v < hi
  181. def zone_net_score(row) -> int:
  182. """区域净分 = 舒适规则命中数 − 黑暗规则命中数"""
  183. def hit(c1, lo1, hi1, c2, lo2, hi2):
  184. if not _in_range(row.get(c1, float('nan')), lo1, hi1):
  185. return False
  186. if c2 is None:
  187. return True
  188. return _in_range(row.get(c2, float('nan')), lo2, hi2)
  189. comfort = sum(1 for r in COMFORT_RULES if hit(*r[:6]))
  190. dark = sum(1 for r in DARK_RULES if hit(*r[:6]))
  191. return comfort - dark
  192. # ──────────────────────────────────────────────────────────────────
  193. # 加分模型(Version D 使用,阈值≥5)
  194. # ──────────────────────────────────────────────────────────────────
  195. def comfort_score(row) -> int:
  196. s = 0
  197. ms = str(row.get('市场状态', ''))
  198. vl = str(row.get('波动率水平', ''))
  199. rsi = str(row.get('RSI区域', ''))
  200. vq = row.get('波动率分位', float('nan'))
  201. rq = row.get('RSI分位', float('nan'))
  202. ts = row.get('趋势强度', float('nan'))
  203. t1 = str(row.get('T1调整', '')) # '否' 或 '是(T0→T1)'
  204. if ms == '下跌趋势高波' and vl == '极低': s += 3 # 最优组合
  205. if pd.notna(rq) and 0.05 <= rq < 0.10: s += 3 # 最优RSI区间
  206. if pd.notna(vq) and vq < 0.30: s += 2 # 低波动率分位
  207. if pd.notna(ts) and 1.5 <= ts < 4.0: s += 2 # 适中趋势强度
  208. if rsi == '中性偏弱': s += 2 # 最稳定RSI
  209. if 'T0' not in t1: s += 2 # 非T0延期单
  210. if vl in ('极低', '低', '中等'): s += 1 # 低中波动率
  211. if pd.notna(rq) and rq >= 0.60: s += 1 # RSI回升阶段
  212. return s
  213. # ──────────────────────────────────────────────────────────────────
  214. # 组合规则(走前向验证后锁定)
  215. # ──────────────────────────────────────────────────────────────────
  216. COMBO_RULES = [
  217. {'RSI区域': '中性偏弱', 'rsi_bin': 'rsi偏低'}, # 规则1: 训练67% → 验证80%
  218. {'vol_bin': 'vol中', 'ts_bin': 'ts强'}, # 规则2: 训练57% → 验证60%
  219. {'波动率水平': '中等', 'ts_bin': 'ts强'}, # 规则3: 训练57% → 验证60%
  220. {'市场状态': '震荡高波', 'RSI区域': '中性偏弱'}, # 规则4: 训练57% → 验证50%
  221. {'RSI区域': '中性偏弱', 'ts_bin': 'ts弱'}, # 规则5: 训练57% → 验证50%
  222. ]
  223. def hits_combo(row) -> bool:
  224. for rule in COMBO_RULES:
  225. if all(str(row.get(col, '')) == str(val) for col, val in rule.items()):
  226. return True
  227. return False
  228. # ──────────────────────────────────────────────────────────────────
  229. # 权益曲线模拟(支持仓位乘数)
  230. # ──────────────────────────────────────────────────────────────────
  231. def simulate_equity(df, initial=INITIAL):
  232. df = df.copy().reset_index(drop=True)
  233. if 'pnl_mult' not in df.columns:
  234. df['pnl_mult'] = 1.0
  235. cap = float(initial)
  236. caps = []
  237. for _, r in df.iterrows():
  238. cap += float(r['盈亏金额']) * float(r['pnl_mult'])
  239. caps.append(cap)
  240. df['资金余额'] = caps
  241. df['实际盈亏'] = df['盈亏金额'] * df['pnl_mult']
  242. df['盈利'] = df['实际盈亏'] > 0
  243. return df
  244. # ──────────────────────────────────────────────────────────────────
  245. # 绩效统计
  246. # ──────────────────────────────────────────────────────────────────
  247. def calc_stats(df, initial=INITIAL):
  248. if len(df) == 0:
  249. return None
  250. wr = df['盈利'].mean()
  251. pnl = df['实际盈亏'].sum()
  252. cap = df['资金余额'].iloc[-1]
  253. ret = (cap - initial) / initial
  254. win = df[df['盈利']]['实际盈亏']
  255. los = df[~df['盈利']]['实际盈亏']
  256. plr = abs(win.mean() / los.mean()) if len(los) > 0 and los.mean() != 0 else float('inf')
  257. eq = df['资金余额'].values
  258. pk = np.maximum.accumulate(np.append([initial], eq))
  259. dd = ((eq - pk[1:]) / pk[1:]).min() if len(eq) > 0 else 0
  260. return dict(n=len(df), wr=wr, pnl=pnl, cap=cap, ret=ret, plr=plr, dd=dd)
  261. def print_yearly(df, initial=INITIAL):
  262. if len(df) == 0:
  263. return
  264. df = df.copy()
  265. df['年份'] = pd.to_datetime(df['开仓时间']).dt.year
  266. prev = initial
  267. for y in sorted(df['年份'].unique()):
  268. sy = df[df['年份'] == y]
  269. pnl = sy['实际盈亏'].sum()
  270. wr = sy['盈利'].mean()
  271. end = sy['资金余额'].iloc[-1]
  272. print(f" {y}年: {len(sy):>3}笔 胜率{wr:.1%} | {pnl:>+12,.0f}元 ({pnl/prev:>+.2%}) → {end:,.0f}元")
  273. prev = end
  274. def print_regime_breakdown(df):
  275. if len(df) == 0 or '市场状态' not in df.columns:
  276. return
  277. print(f" {'市场状态':<15} {'笔数':>5} {'胜率':>7} {'均盈亏':>10} {'总盈亏':>12}")
  278. print(f" {'-'*15} {'-'*5} {'-'*7} {'-'*10} {'-'*12}")
  279. for ms in sorted(df['市场状态'].unique()):
  280. sub = df[df['市场状态'] == ms]
  281. wr = sub['盈利'].mean()
  282. avg = sub['实际盈亏'].mean()
  283. tot = sub['实际盈亏'].sum()
  284. print(f" {ms:<15} {len(sub):>5} {wr:>7.1%} {avg:>+10,.0f} {tot:>+12,.0f}")
  285. # ──────────────────────────────────────────────────────────────────
  286. # 主流程
  287. # ──────────────────────────────────────────────────────────────────
  288. def main():
  289. print(SEP)
  290. print(' CYB50 T+1 最终策略回测(完整K线管道版)')
  291. print(f' Version D (排死亡区+评分≥5) vs Version F (D + 组合规则加仓{BOOST_MULT}x)')
  292. print(SEP)
  293. # ── 步骤1: 加载原始K线 CSV ───────────────────────────────────
  294. print(f'\n📂 步骤1: 加载原始K线数据...')
  295. raw_csv = pd.read_csv(DATA_CSV, encoding='utf-8-sig')
  296. fetcher = IntradayDataFetcher()
  297. with ctx():
  298. data = fetcher._process_dataframe(
  299. raw_csv,
  300. pd.Timestamp('2000-01-01'),
  301. pd.Timestamp('2099-12-31')
  302. )
  303. print(f' K线: {len(data)}条 {data.index[0].date()} ~ {data.index[-1].date()}')
  304. # ── 步骤2: 计算技术指标 ──────────────────────────────────────
  305. print(f'\n📈 步骤2: 计算技术指标...')
  306. with ctx():
  307. data_with_ind = fetcher.calculate_intraday_indicators(data)
  308. ind_cols = [c for c in ['RSI','K','D','J','MACD','MACD_hist','BB_width',
  309. 'ATR_Pct','Momentum','Volume_Ratio'] if c in data_with_ind.columns]
  310. print(f' 指标列: {ind_cols}')
  311. # ── 步骤3: 生成信号 & 执行多空双向交易 ──────────────────────
  312. print(f'\n🔄 步骤3: 生成多空双向信号并执行交易...')
  313. gen = DualDirectionSignalGenerator()
  314. with ctx():
  315. signals_df = gen.generate_dual_direction_signals(data_with_ind)
  316. executor = DualDirectionExecutor(initial_capital=INITIAL)
  317. with ctx():
  318. _, trades_df = executor.execute_dual_direction_trades(signals_df)
  319. long_trades = trades_df[trades_df['交易方向'] == '做多'].copy()
  320. short_trades = trades_df[trades_df['交易方向'] == '做空'].copy()
  321. print(f' 总交易: {len(trades_df)}笔 做多: {len(long_trades)}笔 做空: {len(short_trades)}笔')
  322. # ── 步骤4: T+1规则转换(仅做多) ────────────────────────────
  323. print(f'\n📅 步骤4: T+1规则转换(做多交易)...')
  324. with ctx():
  325. t1_trades = simulate_t1_trades(data_with_ind, long_trades, INITIAL)
  326. if len(t1_trades) == 0:
  327. print(' ⚠️ T+1转换后无交易记录,退出。')
  328. return
  329. t1_adj = (t1_trades['T+1调整'] == '是(T0→T1)').sum()
  330. print(f' T+1交易: {len(t1_trades)}笔 其中T0→T1调整: {t1_adj}笔')
  331. # ── 步骤5: 计算市场环境 & 标注每笔交易 ──────────────────────
  332. print(f'\n🔬 步骤5: 市场环境分析 & 交易标注...')
  333. market_analyzer = MarketEnvironmentAnalyzer(data_with_ind)
  334. cza = ComfortZoneAnalyzer(t1_trades, market_analyzer)
  335. with ctx():
  336. cza._enrich_trades_with_environment()
  337. enriched = cza.enriched_trades.copy()
  338. print(f' 成功标注: {len(enriched)}笔交易')
  339. # ── 规范化列名 & 数值类型 ────────────────────────────────────
  340. # T+1调整 → T1调整(供 comfort_score 使用)
  341. if 'T+1调整' in enriched.columns:
  342. enriched.rename(columns={'T+1调整': 'T1调整'}, inplace=True)
  343. enriched['开仓时间'] = pd.to_datetime(enriched['开仓时间'])
  344. enriched['平仓时间'] = pd.to_datetime(enriched['平仓时间'])
  345. for c in ['盈亏金额', '盈亏百分比', '波动率分位', 'RSI分位', '趋势强度',
  346. '布林带位置', '1日动量', '成交量分位']:
  347. if c in enriched.columns:
  348. enriched[c] = pd.to_numeric(enriched[c], errors='coerce')
  349. enriched = enriched.sort_values('开仓时间').reset_index(drop=True)
  350. enriched = add_bins(enriched)
  351. # USE_FEES 开关
  352. if not USE_FEES:
  353. enriched['盈亏金额'] = (enriched['平仓价格'] - enriched['开仓价格']) * enriched['仓位']
  354. fee_label = '不含手续费(按价差×数量重算)'
  355. else:
  356. fee_label = '含手续费(t1_converter计算)'
  357. print(f' 数据区间: {enriched["开仓时间"].min().date()} ~ {enriched["开仓时间"].max().date()}')
  358. print(f' 手续费模式: {fee_label}')
  359. # ── 步骤6: 评分 & 附加日线/K线指标 ──────────────────────────
  360. print(f'\n🎯 步骤6: 评分 & 附加日线指标 & 构建 B/D/F/G...')
  361. enriched['_score'] = enriched.apply(comfort_score, axis=1)
  362. enriched['_combo_hit'] = enriched.apply(hits_combo, axis=1)
  363. enriched['_year'] = enriched['开仓时间'].dt.year
  364. # 附加 K线+日线指标,计算区域净分
  365. daily = build_daily_indicators(data_with_ind)
  366. enriched = attach_zone_indicators(enriched, data_with_ind, daily)
  367. enriched['_zone_net'] = enriched.apply(zone_net_score, axis=1)
  368. enriched['_zone'] = enriched['_zone_net'].apply(
  369. lambda n: '舒适' if n >= 2 else ('黑暗' if n <= -2 else '中性'))
  370. not_dead = ~enriched['市场状态'].isin(DEATH_ZONES)
  371. high_score = enriched['_score'] >= 5
  372. not_dark = enriched['_zone'] != '黑暗'
  373. vB_df = enriched[not_dead].copy(); vB_df['pnl_mult'] = 1.0
  374. vD_df = enriched[not_dead & high_score].copy(); vD_df['pnl_mult'] = 1.0
  375. vF_df = enriched[not_dead & high_score].copy()
  376. vF_df['pnl_mult'] = vF_df['_combo_hit'].apply(lambda x: BOOST_MULT if x else 1.0)
  377. # Version G:排死亡区 + 评分≥5 + 跳过黑暗区 + 舒适区×1.5仓位
  378. vG_df = enriched[not_dead & high_score & not_dark].copy()
  379. vG_df['pnl_mult'] = vG_df['_zone'].apply(lambda z: BOOST_MULT if z == '舒适' else 1.0)
  380. vB = simulate_equity(vB_df)
  381. vD = simulate_equity(vD_df)
  382. vF = simulate_equity(vF_df)
  383. vG = simulate_equity(vG_df)
  384. sB, sD, sF, sG = calc_stats(vB), calc_stats(vD), calc_stats(vF), calc_stats(vG)
  385. # ════════════════════════════════════════════════════════════
  386. # 输出:回测结果对比
  387. # ════════════════════════════════════════════════════════════
  388. print()
  389. print(SEP)
  390. print(' 版本说明')
  391. print(SEP)
  392. print(f' Version B = 排死亡区(基准) {sB["n"] if sB else 0}笔')
  393. print(f' Version D = 排死亡区 + 评分≥5 {sD["n"] if sD else 0}笔')
  394. print(f' Version F = 排死亡区 + 评分≥5 + 组合规则加仓{BOOST_MULT}x {sF["n"] if sF else 0}笔')
  395. print(f' Version G = 排死亡区 + 评分≥5 + 区域过滤 + 舒适区{BOOST_MULT}x {sG["n"] if sG else 0}笔')
  396. print()
  397. print(SEP)
  398. print(' 回测结果对比')
  399. print(SEP)
  400. def fv(s, k, fmt):
  401. if s is None: return 'N/A'
  402. v = s[k]
  403. if fmt == 'n': return str(int(v))
  404. if fmt == 'pct': return f'{v:.1%}'
  405. if fmt == 'pct2': return f'{v:+.2%}'
  406. if fmt == 'f2': return f'{v:.2f}'
  407. if fmt == 'money':return f'{v:+,.0f}'
  408. if fmt == 'cap': return f'{v:,.0f}'
  409. return str(v)
  410. hdr = (f' {"指标":<14} {"Version B(基准)":>14} {"Version D":>12}'
  411. f' {"Version F(+加仓)":>16} {"Version G(区域)":>16}')
  412. print(hdr)
  413. print(' ' + '-' * 74)
  414. for name, k, fmt in [
  415. ('交易笔数', 'n', 'n'),
  416. ('胜率', 'wr', 'pct'),
  417. ('盈亏比', 'plr', 'f2'),
  418. ('总收益率', 'ret', 'pct2'),
  419. ('最终资金', 'cap', 'cap'),
  420. ('总盈亏', 'pnl', 'money'),
  421. ('最大回撤', 'dd', 'pct'),
  422. ]:
  423. print(f' {name:<14} {fv(sB,k,fmt):>14} {fv(sD,k,fmt):>12}'
  424. f' {fv(sF,k,fmt):>16} {fv(sG,k,fmt):>16}')
  425. # ── 年度明细 ─────────────────────────────────────────────────
  426. print(f'\n Version B(排死亡区)年度明细:')
  427. print_yearly(vB)
  428. print(f'\n Version D(评分≥5)年度明细:')
  429. print_yearly(vD)
  430. print(f'\n Version F(评分≥5 + 加仓)年度明细:')
  431. print_yearly(vF)
  432. print(f'\n Version G(区域过滤 + 舒适区加仓)年度明细:')
  433. print_yearly(vG)
  434. # ── 组合规则命中分析 ─────────────────────────────────────────
  435. print(f'\n{SEP}')
  436. print(' 组合规则命中分析(在评分≥5的交易中)')
  437. print(SEP)
  438. combo_hit = vF_df[vF_df['_combo_hit']].copy()
  439. combo_hit['实际盈亏'] = combo_hit['盈亏金额'] * BOOST_MULT
  440. combo_hit['盈利'] = combo_hit['实际盈亏'] > 0
  441. combo_miss = vF_df[~vF_df['_combo_hit']].copy()
  442. combo_miss['实际盈亏'] = combo_miss['盈亏金额'] * 1.0
  443. combo_miss['盈利'] = combo_miss['实际盈亏'] > 0
  444. print(f'\n 命中规则: {len(combo_hit)}笔 '
  445. f'| 胜率{combo_hit["盈利"].mean():.1%} '
  446. f'| 均盈亏{combo_hit["实际盈亏"].mean():+,.0f}元 '
  447. f'| 仓位乘数{BOOST_MULT}x')
  448. print(f' 未命中: {len(combo_miss)}笔 '
  449. f'| 胜率{combo_miss["盈利"].mean():.1%} '
  450. f'| 均盈亏{combo_miss["实际盈亏"].mean():+,.0f}元 '
  451. f'| 仓位乘数1.0x')
  452. combo_hit['_year'] = pd.to_datetime(combo_hit['开仓时间']).dt.year
  453. print(f'\n 命中规则年份分布:')
  454. print(f' {"年份":>5} {"命中笔数":>8} {"胜率":>7} {"总盈亏(1.5x)":>14}')
  455. print(f' {"-----":>5} {"--------":>8} {"-------":>7} {"----------":>14}')
  456. for y in sorted(enriched['_year'].unique()):
  457. sy = combo_hit[combo_hit['_year'] == y]
  458. if len(sy) == 0:
  459. print(f' {y:>5} {"0":>8} {"—":>7} {"0":>14}')
  460. else:
  461. print(f' {y:>5} {len(sy):>8} {sy["盈利"].mean():>7.1%} {sy["实际盈亏"].sum():>+14,.0f}')
  462. # ── 市场状态分布(Version F)────────────────────────────────
  463. print(f'\n{SEP}')
  464. print(' Version F 各市场状态表现')
  465. print(SEP)
  466. print()
  467. print_regime_breakdown(vF)
  468. # ── Version G 区域分布说明 ───────────────────────────────────
  469. print(f'\n{SEP}')
  470. print(' Version G — 区域分布(在 Version D 85笔中)')
  471. print(SEP)
  472. vD_z = enriched[not_dead & high_score].copy()
  473. zone_grp = vD_z.groupby('_zone').agg(
  474. n=('盈亏金额', 'count'),
  475. wr=('盈亏金额', lambda x: (x > 0).mean()),
  476. avg_pnl=('盈亏金额', 'mean'),
  477. total_pnl=('盈亏金额', 'sum'),
  478. ).reset_index()
  479. print(f'\n {"区域":<6} {"笔数":>5} {"胜率":>7} {"均盈亏":>10} {"总盈亏":>12} {"Version G操作"}')
  480. print(f' {"-"*6} {"-"*5} {"-"*7} {"-"*10} {"-"*12} {"-"*16}')
  481. action_map = {'舒适': f'入场 ×{BOOST_MULT} 仓位', '中性': '入场 ×1.0 仓位', '黑暗': '⛔ 跳过'}
  482. for _, r in zone_grp.sort_values('wr', ascending=False).iterrows():
  483. act = action_map.get(r['_zone'], '')
  484. print(f' {r["_zone"]:<6} {int(r["n"]):>5} {r["wr"]:>7.1%}'
  485. f' {r["avg_pnl"]:>+10,.0f} {r["total_pnl"]:>+12,.0f} {act}')
  486. # 各年黑暗区过滤效果
  487. print(f'\n 黑暗区过滤效果(Version D → Version G 减少笔数):')
  488. print(f' {"年份":>5} {"D总笔":>6} {"G入场":>6} {"过滤":>5} {"D总盈亏":>12} {"G总盈亏":>12} {"改善":>12}')
  489. print(f' {"-"*5} {"-"*6} {"-"*6} {"-"*5} {"-"*12} {"-"*12} {"-"*12}')
  490. for y in sorted(enriched['_year'].unique()):
  491. d_y = vD[vD['开仓时间'].dt.year == y] if '开仓时间' in vD.columns else pd.DataFrame()
  492. g_y = vG[vG['开仓时间'].dt.year == y] if '开仓时间' in vG.columns else pd.DataFrame()
  493. nd = len(d_y); ng = len(g_y)
  494. pd_ = d_y['实际盈亏'].sum() if nd else 0
  495. pg_ = g_y['实际盈亏'].sum() if ng else 0
  496. print(f' {y:>5} {nd:>6} {ng:>6} {nd-ng:>5}'
  497. f' {pd_:>+12,.0f} {pg_:>+12,.0f} {pg_-pd_:>+12,.0f}')
  498. # ── K线指标详情(Version F 最近20笔)────────────────────────
  499. print(f'\n{SEP}')
  500. print(' Version F 最近20笔 — 入场时K线指标')
  501. print(SEP)
  502. print(f'\n {"":1} {"开仓时间":<17} {"市场状态":<12} {"评分":>4} {"组合":>4}'
  503. f' {"RSI":>6} {"K":>6} {"D":>6} {"J":>6}'
  504. f' {"BB位置":>6} {"动量%":>7} {"量比":>6} {"实际盈亏":>10}')
  505. print(f' {"-"*1} {"-"*17} {"-"*12} {"-"*4} {"-"*4}'
  506. f' {"-"*6} {"-"*6} {"-"*6} {"-"*6}'
  507. f' {"-"*6} {"-"*7} {"-"*6} {"-"*10}')
  508. for _, r in vF.tail(20).iterrows():
  509. t = r['开仓时间']
  510. win = '★' if r['盈利'] else '✗'
  511. boost = 'HIT' if r['pnl_mult'] > 1.0 else ' '
  512. sc = int(enriched.loc[enriched['开仓时间'] == t, '_score'].iloc[0]) \
  513. if (enriched['开仓时间'] == t).any() else -1
  514. ms = str(r.get('市场状态', ''))
  515. # K线指标
  516. if t in data_with_ind.index:
  517. bar = data_with_ind.loc[t]
  518. rsi_v = bar.get('RSI', float('nan'))
  519. k_v = bar.get('K', float('nan'))
  520. d_v = bar.get('D', float('nan'))
  521. j_v = bar.get('J', float('nan'))
  522. bb_u = bar.get('BB_upper', float('nan'))
  523. bb_l = bar.get('BB_lower', float('nan'))
  524. bb_p = (bar.get('Close', float('nan')) - bb_l) / (bb_u - bb_l + 1e-9)
  525. mom = bar.get('Momentum', float('nan')) * 100
  526. vr = bar.get('Volume_Ratio', float('nan'))
  527. else:
  528. rsi_v = k_v = d_v = j_v = bb_p = mom = vr = float('nan')
  529. print(f' {win} {str(t)[:16]} {ms:<12} {sc:>4} {boost:>4}'
  530. f' {rsi_v:>6.1f} {k_v:>6.1f} {d_v:>6.1f} {j_v:>6.1f}'
  531. f' {bb_p:>6.2f} {mom:>7.2f} {vr:>6.2f} {r["实际盈亏"]:>+10,.0f}')
  532. # ── 评分分布 ─────────────────────────────────────────────────
  533. print(f'\n{SEP}')
  534. print(' 评分分布(非死亡区全量)')
  535. print(SEP)
  536. nondead = enriched[not_dead]
  537. print(f'\n {"评分":>5} {"笔数":>5} {"胜率":>7} {"均盈亏":>10} {"累计盈亏":>12} {"选入D/F?":>8}')
  538. print(f' {"-"*5} {"-"*5} {"-"*7} {"-"*10} {"-"*12} {"-"*8}')
  539. for sc in sorted(nondead['_score'].unique()):
  540. sub = nondead[nondead['_score'] == sc]
  541. wr = (sub['盈亏金额'] > 0).mean()
  542. avg = sub['盈亏金额'].mean()
  543. tot = sub['盈亏金额'].sum()
  544. flag = '✓ 入场' if sc >= 5 else ' 跳过'
  545. print(f' {sc:>5} {len(sub):>5} {wr:>7.1%} {avg:>+10,.0f} {tot:>+12,.0f} {flag}')
  546. # ── CSV 导出 ─────────────────────────────────────────────────
  547. if EXPORT_CSV:
  548. ts = datetime.now().strftime('%Y%m%d_%H%M%S')
  549. fee_tag = 'nofee' if not USE_FEES else 'fee'
  550. out_dir = os.path.dirname(__file__)
  551. # K线指标列(入场时刻)
  552. kline_cols = [c for c in ['RSI','K','D','J','MACD','MACD_hist',
  553. 'BB_width','ATR_Pct','Momentum','Volume_Ratio']
  554. if c in data_with_ind.columns]
  555. def attach_kline(df_in):
  556. """给 df_in 每行附加入场时刻的K线指标"""
  557. rows = []
  558. for t in df_in['开仓时间']:
  559. if t in data_with_ind.index:
  560. rows.append(data_with_ind.loc[t, kline_cols].to_dict())
  561. else:
  562. rows.append({c: float('nan') for c in kline_cols})
  563. return pd.DataFrame(rows, index=df_in.index)
  564. base_cols = [
  565. '交易方向', '开仓时间', '平仓时间', '开仓价格', '平仓价格', '仓位',
  566. '盈亏金额', '盈亏百分比', '退出原因', 'T1调整',
  567. '市场状态', '波动率水平', '波动率分位', 'RSI区域', 'RSI分位',
  568. '趋势强度', '布林带区域', '_score', 'vol_bin', 'rsi_bin', 'ts_bin',
  569. '成交量分位', '布林带位置', '1日动量',
  570. '_zone_net', '_zone', # Version G 区域字段
  571. ]
  572. def build_export(df, label):
  573. out = df.copy().reset_index(drop=True)
  574. out.insert(0, '序号', range(1, len(out) + 1))
  575. out['版本'] = label
  576. out['手续费模式'] = fee_label
  577. out['仓位乘数'] = out.get('pnl_mult', pd.Series([1.0]*len(out)))
  578. out['实际盈亏'] = out['盈亏金额'] * out['仓位乘数']
  579. out['盈利标记'] = out['实际盈亏'].apply(lambda x: '盈' if x > 0 else '亏')
  580. out['验证盈亏%'] = ((out['平仓价格'] - out['开仓价格'])
  581. / out['开仓价格'] * 100).round(4)
  582. # 附加K线指标
  583. kl = attach_kline(out)
  584. for c in kl.columns:
  585. out[c] = kl[c].values
  586. keep = (['序号', '版本', '手续费模式', '盈利标记', '仓位乘数',
  587. '实际盈亏', '资金余额'] +
  588. [c for c in base_cols if c in out.columns] +
  589. kline_cols +
  590. ['验证盈亏%'])
  591. keep = [c for c in keep if c in out.columns]
  592. return out[keep]
  593. print(f'\n📁 导出CSV...')
  594. exp_B = build_export(vB, 'B_排死亡区')
  595. exp_D = build_export(vD, 'D_评分≥5')
  596. exp_F = build_export(vF, 'F_评分≥5+加仓')
  597. exp_G = build_export(vG, 'G_区域过滤')
  598. for label, exp in [('B', exp_B), ('D', exp_D), ('F', exp_F), ('G', exp_G)]:
  599. fname = os.path.join(out_dir, f'backtest_v{label}_{fee_tag}_{ts}.csv')
  600. exp.to_csv(fname, index=False, encoding='utf-8-sig')
  601. print(f' Version {label}: {os.path.basename(fname)} ({len(exp)}笔)')
  602. combined = pd.concat([exp_B, exp_D, exp_F, exp_G], ignore_index=True)
  603. fname_all = os.path.join(out_dir, f'backtest_all_{fee_tag}_{ts}.csv')
  604. combined.to_csv(fname_all, index=False, encoding='utf-8-sig')
  605. print(f' 合并总表: {os.path.basename(fname_all)} ({len(combined)}笔)')
  606. print()
  607. print(SEP)
  608. print(' 回测完成')
  609. print(SEP)
  610. if __name__ == '__main__':
  611. main()