signal_report_t1.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. CYB50 T+1 舒适区交易信号报告
  5. ──────────────────────────────────────────────────────────────────────
  6. 在 auto_report_long_only_t1.py 的基础上融合最终策略(Version F):
  7. 流程:
  8. 1. 加载本地30分钟K线数据
  9. 2. 运行多空双向策略 → 提取做多交易 → T+1规则转换
  10. 3. 计算市场环境指标(MarketEnvironmentAnalyzer)
  11. 4. 应用 Version F 过滤:
  12. - 死亡区(下跌趋势低波 / 震荡低波)→ 跳过
  13. - 加分模型评分 ≥ 5 → 正常仓位
  14. - 命中组合规则 → 仓位 ×1.5
  15. 5. 计算今日信号(当日开仓 / 当日平仓 / 无操作)
  16. 6. 生成 HTML 报告并发送邮件
  17. """
  18. import sys
  19. import os
  20. if sys.platform == 'win32':
  21. import io
  22. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
  23. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
  24. import pandas as pd
  25. import numpy as np
  26. from datetime import datetime, timedelta
  27. import smtplib
  28. from email.mime.text import MIMEText
  29. from email.mime.multipart import MIMEMultipart
  30. from email.header import Header
  31. import warnings
  32. warnings.filterwarnings('ignore')
  33. from cyb50_30min_dual_direction import (
  34. ConfigManager, IntradayDataFetcher,
  35. DualDirectionSignalGenerator, DualDirectionExecutor
  36. )
  37. from t1_converter import simulate_t1_trades
  38. from comfort_zone_analyzer import MarketEnvironmentAnalyzer, ComfortZoneAnalyzer
  39. # ══════════════════════════════════════════════════════════════════
  40. # 配置项
  41. # ══════════════════════════════════════════════════════════════════
  42. EMAIL_CONFIG = {
  43. "smtp_server": "localhost",
  44. "smtp_port": 25,
  45. "sender_email": "cyb50-t1@erwin.wang",
  46. "receiver_emails": ["380880504@qq.com"],
  47. }
  48. INITIAL_CAPITAL = 1_000_000
  49. FETCH_DAYS = 90 # 拉取最近N天数据(含指标预热期)
  50. REPORT_DAYS = 60 # 报告统计区间(最近N天交易)
  51. # Version F 策略参数
  52. DEATH_ZONES = {'下跌趋势低波', '震荡低波'}
  53. SCORE_THRESH = 5 # 最低入场评分
  54. BOOST_MULT = 1.5 # 命中组合规则时的仓位乘数
  55. # ══════════════════════════════════════════════════════════════════
  56. # 最终策略核心逻辑(与 final_strategy.py 保持一致)
  57. # ══════════════════════════════════════════════════════════════════
  58. def vol_bin(v):
  59. if pd.isna(v): return 'unknown'
  60. if v < 0.20: return 'vol极低'
  61. if v < 0.40: return 'vol低'
  62. if v < 0.60: return 'vol中'
  63. if v < 0.80: return 'vol高'
  64. return 'vol极高'
  65. def rsi_bin(v):
  66. if pd.isna(v): return 'unknown'
  67. if v < 0.05: return 'rsi极底'
  68. if v < 0.10: return 'rsi底'
  69. if v < 0.20: return 'rsi低'
  70. if v < 0.40: return 'rsi偏低'
  71. if v < 0.60: return 'rsi中'
  72. if v < 0.80: return 'rsi偏高'
  73. return 'rsi高'
  74. def ts_bin(v):
  75. if pd.isna(v): return 'unknown'
  76. if v < 1.0: return 'ts弱'
  77. if v < 1.5: return 'ts中弱'
  78. if v < 2.5: return 'ts中'
  79. if v < 4.0: return 'ts强'
  80. return 'ts极强'
  81. def comfort_score(row) -> int:
  82. s = 0
  83. ms = str(row.get('市场状态', ''))
  84. vl = str(row.get('波动率水平', ''))
  85. rsi = str(row.get('RSI区域', ''))
  86. vq = row.get('波动率分位', float('nan'))
  87. rq = row.get('RSI分位', float('nan'))
  88. ts = row.get('趋势强度', float('nan'))
  89. t1 = str(row.get('T+1调整', row.get('T1调整', '')))
  90. if ms == '下跌趋势高波' and vl == '极低': s += 3
  91. if pd.notna(rq) and 0.05 <= rq < 0.10: s += 3
  92. if pd.notna(vq) and vq < 0.30: s += 2
  93. if pd.notna(ts) and 1.5 <= ts < 4.0: s += 2
  94. if rsi == '中性偏弱': s += 2
  95. if 'T0' not in t1: s += 2
  96. if vl in ('极低', '低', '中等'): s += 1
  97. if pd.notna(rq) and rq >= 0.60: s += 1
  98. return s
  99. COMBO_RULES = [
  100. {'RSI区域': '中性偏弱', 'rsi_bin': 'rsi偏低'},
  101. {'vol_bin': 'vol中', 'ts_bin': 'ts强'},
  102. {'波动率水平': '中等', 'ts_bin': 'ts强'},
  103. {'市场状态': '震荡高波', 'RSI区域': '中性偏弱'},
  104. {'RSI区域': '中性偏弱', 'ts_bin': 'ts弱'},
  105. ]
  106. def hits_combo(row) -> bool:
  107. for rule in COMBO_RULES:
  108. if all(str(row.get(col, '')) == str(val) for col, val in rule.items()):
  109. return True
  110. return False
  111. def add_strategy_labels(df: pd.DataFrame) -> pd.DataFrame:
  112. """为已包含市场环境列的 DataFrame 追加评分、组合命中、仓位乘数列"""
  113. df = df.copy()
  114. df['vol_bin'] = df['波动率分位'].apply(vol_bin)
  115. df['rsi_bin'] = df['RSI分位'].apply(rsi_bin)
  116. df['ts_bin'] = df['趋势强度'].apply(ts_bin)
  117. df['评分'] = df.apply(comfort_score, axis=1)
  118. df['组合命中'] = df.apply(hits_combo, axis=1)
  119. df['死亡区'] = df['市场状态'].isin(DEATH_ZONES)
  120. df['入场有效'] = (~df['死亡区']) & (df['评分'] >= SCORE_THRESH)
  121. df['仓位乘数'] = df.apply(
  122. lambda r: BOOST_MULT if (r['入场有效'] and r['组合命中']) else 1.0, axis=1
  123. )
  124. df['实际盈亏'] = df['盈亏金额'] * df.apply(
  125. lambda r: r['仓位乘数'] if r['入场有效'] else 0.0, axis=1
  126. )
  127. df['入场建议'] = df.apply(_entry_advice, axis=1)
  128. return df
  129. def _entry_advice(row) -> str:
  130. if row['死亡区']:
  131. return f'⛔ 跳过(死亡区:{row["市场状态"]})'
  132. if row['评分'] < SCORE_THRESH:
  133. return f'⏸ 观望(评分{row["评分"]} < {SCORE_THRESH})'
  134. if row['组合命中']:
  135. return f'✅ 入场 {BOOST_MULT}x 仓(评分{row["评分"]},组合规则命中)'
  136. return f'✅ 入场 1x 仓(评分{row["评分"]})'
  137. # ══════════════════════════════════════════════════════════════════
  138. # 实时数据获取
  139. # ══════════════════════════════════════════════════════════════════
  140. def fetch_data(fetcher) -> pd.DataFrame:
  141. """
  142. 拉取最近 FETCH_DAYS 天的30分钟K线(东方财富主,新浪备用)。
  143. 多取 FETCH_DAYS 天是为了保证 MarketEnvironmentAnalyzer 的指标预热(需要 120 根以上K线)。
  144. 实际回测报告只展示最近 REPORT_DAYS 天的交易。
  145. """
  146. end_date = datetime.now()
  147. start_date = end_date - timedelta(days=FETCH_DAYS)
  148. print(f'📡 在线获取30分钟K线数据(最近 {FETCH_DAYS} 天)...')
  149. print(f' 请求区间: {start_date.strftime("%Y-%m-%d")} ~ {end_date.strftime("%Y-%m-%d")}')
  150. df = fetcher.fetch_30min_data(start_date=start_date, end_date=end_date)
  151. if df is None or df.empty:
  152. raise RuntimeError('数据获取失败,所有数据源均返回空')
  153. print(f' 实际区间: {df.index[0]} ~ {df.index[-1]} ({len(df)} 条K线)')
  154. return df
  155. # ══════════════════════════════════════════════════════════════════
  156. # 今日信号分析
  157. # ══════════════════════════════════════════════════════════════════
  158. def analyse_today(labeled_df: pd.DataFrame, data_with_ind: pd.DataFrame,
  159. market_analyzer, cza):
  160. """
  161. 返回当日相关交易与当前市场快照。
  162. labeled_df: 经过 add_strategy_labels() 处理的完整 T+1 交易记录
  163. data_with_ind: 含技术指标的 K 线数据(用于获取当前市场状态)
  164. market_analyzer: 已初始化的 MarketEnvironmentAnalyzer
  165. cza: 已初始化的 ComfortZoneAnalyzer(用于分类函数)
  166. """
  167. today = datetime.now().date()
  168. # 当日开仓(买入信号,T+1 明日平仓)
  169. opened_today = labeled_df[
  170. pd.to_datetime(labeled_df['开仓时间']).dt.date == today
  171. ]
  172. # 当日平仓(昨日买入,今日卖出)
  173. closing_today = labeled_df[
  174. pd.to_datetime(labeled_df['平仓时间']).dt.date == today
  175. ]
  176. # 当前市场快照(取最新一根K线)
  177. last_bar = data_with_ind.iloc[-1]
  178. env = market_analyzer.get_environment_at_time(data_with_ind.index[-1])
  179. if env is None:
  180. env = {}
  181. # 将 env 转为伪行以便计算评分
  182. current_row = {
  183. '市场状态': env.get('market_regime', '未知'),
  184. '波动率水平': cza._classify_volatility(env.get('volatility_percentile', 0.5)),
  185. 'RSI区域': cza._classify_rsi(env.get('rsi', 50)),
  186. '波动率分位': env.get('volatility_percentile', float('nan')),
  187. 'RSI分位': env.get('rsi_percentile', float('nan')),
  188. '趋势强度': env.get('trend_strength', float('nan')),
  189. 'T+1调整': '',
  190. 'vol_bin': vol_bin(env.get('volatility_percentile', float('nan'))),
  191. 'rsi_bin': rsi_bin(env.get('rsi_percentile', float('nan'))),
  192. 'ts_bin': ts_bin(env.get('trend_strength', float('nan'))),
  193. }
  194. current_score = comfort_score(current_row)
  195. current_combo = hits_combo(current_row)
  196. current_dead = current_row['市场状态'] in DEATH_ZONES
  197. current_advice = _entry_advice({
  198. '死亡区': current_dead,
  199. '评分': current_score,
  200. '组合命中': current_combo,
  201. '市场状态': current_row['市场状态'],
  202. })
  203. snapshot = {
  204. 'time': str(data_with_ind.index[-1]),
  205. 'close': last_bar['Close'],
  206. 'market_regime': current_row['市场状态'],
  207. 'vol_level': current_row['波动率水平'],
  208. 'rsi_zone': current_row['RSI区域'],
  209. 'vol_pct': env.get('volatility_percentile', float('nan')),
  210. 'rsi_pct': env.get('rsi_percentile', float('nan')),
  211. 'trend_strength': env.get('trend_strength', float('nan')),
  212. 'rsi_value': env.get('rsi', float('nan')),
  213. 'score': current_score,
  214. 'combo_hit': current_combo,
  215. 'dead_zone': current_dead,
  216. 'advice': current_advice,
  217. }
  218. return opened_today, closing_today, snapshot
  219. # ══════════════════════════════════════════════════════════════════
  220. # HTML 报告生成
  221. # ══════════════════════════════════════════════════════════════════
  222. CSS = """
  223. body{font-family:Arial,sans-serif;margin:20px;color:#333}
  224. h1{border-bottom:3px solid #007bff;padding-bottom:8px;color:#222}
  225. h2{color:#555;margin-top:28px;border-left:4px solid #007bff;padding-left:8px}
  226. table{border-collapse:collapse;width:100%;margin:12px 0;font-size:13px}
  227. th,td{border:1px solid #ddd;padding:7px 10px;text-align:left}
  228. th{background:#007bff;color:#fff}
  229. tr:nth-child(even){background:#f7f9fc}
  230. .pos{color:#0a8a0a;font-weight:bold}
  231. .neg{color:#cc1111;font-weight:bold}
  232. .tag-green{background:#d4edda;color:#155724;border-radius:4px;padding:2px 7px;font-size:12px}
  233. .tag-red{background:#f8d7da;color:#721c24;border-radius:4px;padding:2px 7px;font-size:12px}
  234. .tag-yellow{background:#fff3cd;color:#856404;border-radius:4px;padding:2px 7px;font-size:12px}
  235. .tag-gray{background:#e2e3e5;color:#383d41;border-radius:4px;padding:2px 7px;font-size:12px}
  236. .signal-box{border-radius:8px;padding:16px 20px;margin:12px 0;font-size:15px}
  237. .signal-enter{background:#d4edda;border-left:5px solid #28a745}
  238. .signal-boost{background:#cce5ff;border-left:5px solid #007bff}
  239. .signal-watch{background:#fff3cd;border-left:5px solid #ffc107}
  240. .signal-skip{background:#f8d7da;border-left:5px solid #dc3545}
  241. """
  242. def _pnl_cls(v):
  243. return 'pos' if v >= 0 else 'neg'
  244. def _score_tag(score):
  245. if score >= 5:
  246. return f'<span class="tag-green">评分 {score} ✓</span>'
  247. return f'<span class="tag-red">评分 {score} ✗</span>'
  248. def _regime_tag(regime):
  249. if regime in DEATH_ZONES:
  250. return f'<span class="tag-red">{regime} ⚠</span>'
  251. if '高波' in regime:
  252. return f'<span class="tag-green">{regime}</span>'
  253. return f'<span class="tag-gray">{regime}</span>'
  254. def _advice_box(advice):
  255. if '⛔' in advice:
  256. cls = 'signal-skip'
  257. elif '⏸' in advice:
  258. cls = 'signal-watch'
  259. elif f'{BOOST_MULT}x' in advice:
  260. cls = 'signal-boost'
  261. else:
  262. cls = 'signal-enter'
  263. return f'<div class="signal-box {cls}"><b>{advice}</b></div>'
  264. def generate_report(labeled_df, snapshot, opened_today, closing_today):
  265. now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  266. cutoff = datetime.now() - timedelta(days=REPORT_DAYS)
  267. # ── 近 REPORT_DAYS 天 Version F 过滤后交易 ────────────────────
  268. vF = labeled_df[
  269. labeled_df['入场有效'] &
  270. (pd.to_datetime(labeled_df['开仓时间']) >= cutoff)
  271. ].copy()
  272. vF_cap = INITIAL_CAPITAL
  273. caps = []
  274. for _, r in vF.iterrows():
  275. vF_cap += r['实际盈亏']
  276. caps.append(vF_cap)
  277. if caps:
  278. vF['资金余额'] = caps
  279. else:
  280. vF['资金余额'] = INITIAL_CAPITAL
  281. total_n = len(vF)
  282. total_pnl = vF['实际盈亏'].sum()
  283. total_ret = (vF_cap - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100 if total_n else 0
  284. wr = (vF['实际盈亏'] > 0).mean() * 100 if total_n else 0
  285. wins = vF[vF['实际盈亏'] > 0]['实际盈亏']
  286. loss = vF[vF['实际盈亏'] < 0]['实际盈亏']
  287. plr = abs(wins.mean() / loss.mean()) if len(loss) > 0 and loss.mean() != 0 else 0
  288. eq = vF['资金余额'].values
  289. if len(eq):
  290. pk = np.maximum.accumulate(np.append([INITIAL_CAPITAL], eq))
  291. max_dd = ((eq - pk[1:]) / pk[1:]).min() * 100
  292. else:
  293. max_dd = 0
  294. # ── HTML 构建 ─────────────────────────────────────────────────
  295. html = f"""<html><head><meta charset="utf-8">
  296. <style>{CSS}</style></head><body>
  297. <h1>📡 CYB50 T+1 舒适区交易信号报告</h1>
  298. <p>生成时间:{now_str} | 策略版本:Version F(排死亡区 + 评分≥{SCORE_THRESH} + 组合规则加仓{BOOST_MULT}x)</p>
  299. <p>数据来源:在线实时(东方财富/新浪)| 拉取 <b>{FETCH_DAYS}</b> 天K线(含指标预热)| 报告统计近 <b>{REPORT_DAYS}</b> 天</p>
  300. <h2>🧭 当前市场状态</h2>
  301. <table>
  302. <tr><th>指标</th><th>数值</th><th>说明</th></tr>
  303. <tr><td>最新K线时间</td><td>{snapshot['time']}</td><td></td></tr>
  304. <tr><td>最新收盘价</td><td>{snapshot['close']:.3f}</td><td></td></tr>
  305. <tr><td>市场状态</td><td>{_regime_tag(snapshot['market_regime'])}</td>
  306. <td>{'⚠ 死亡区,新信号跳过' if snapshot['dead_zone'] else '非死亡区,信号有效'}</td></tr>
  307. <tr><td>波动率水平</td><td>{snapshot['vol_level']}</td>
  308. <td>分位 {snapshot['vol_pct']:.2f}</td></tr>
  309. <tr><td>RSI 状态</td><td>{snapshot['rsi_zone']}</td>
  310. <td>RSI={snapshot['rsi_value']:.1f},分位 {snapshot['rsi_pct']:.2f}</td></tr>
  311. <tr><td>趋势强度</td><td>{snapshot['trend_strength']:.2f}</td><td></td></tr>
  312. <tr><td>加分模型评分</td><td>{_score_tag(snapshot['score'])}</td>
  313. <td>阈值 {SCORE_THRESH},{'通过 ✓' if snapshot['score'] >= SCORE_THRESH else '未通过 ✗'}</td></tr>
  314. <tr><td>组合规则</td>
  315. <td>{'<span class="tag-green">命中 → 仓位×1.5</span>' if snapshot['combo_hit'] else '<span class="tag-gray">未命中 → 仓位×1.0</span>'}</td>
  316. <td></td></tr>
  317. </table>
  318. <h2>🎯 入场建议</h2>
  319. {_advice_box(snapshot['advice'])}
  320. """
  321. # ── 今日开仓信号 ──────────────────────────────────────────────
  322. html += f'<h2>📥 今日开仓信号({len(opened_today)} 笔)</h2>'
  323. if len(opened_today):
  324. html += """<table>
  325. <tr><th>开仓时间</th><th>开仓价</th><th>市场状态</th><th>评分</th><th>组合命中</th>
  326. <th>入场建议</th><th>仓位乘数</th></tr>"""
  327. for _, r in opened_today.iterrows():
  328. html += f"""<tr>
  329. <td>{r['开仓时间']}</td><td>{r['开仓价格']:.3f}</td>
  330. <td>{_regime_tag(r.get('市场状态','—'))}</td>
  331. <td>{_score_tag(r.get('评分',0))}</td>
  332. <td>{'<span class="tag-green">是</span>' if r.get('组合命中') else '<span class="tag-gray">否</span>'}</td>
  333. <td>{r.get('入场建议','—')}</td>
  334. <td>{r.get('仓位乘数',1.0):.1f}x</td>
  335. </tr>"""
  336. html += '</table>'
  337. else:
  338. html += '<p>今日暂无新开仓信号。</p>'
  339. # ── 今日平仓 ─────────────────────────────────────────────────
  340. html += f'<h2>📤 今日平仓({len(closing_today)} 笔)</h2>'
  341. if len(closing_today):
  342. html += """<table>
  343. <tr><th>开仓时间</th><th>平仓时间</th><th>开仓价</th><th>平仓价</th>
  344. <th>盈亏</th><th>评分</th><th>仓位乘数</th><th>实际盈亏</th></tr>"""
  345. for _, r in closing_today.iterrows():
  346. pnl = r.get('实际盈亏', r['盈亏金额'])
  347. orig = r['盈亏金额']
  348. html += f"""<tr>
  349. <td>{r['开仓时间']}</td><td>{r['平仓时间']}</td>
  350. <td>{r['开仓价格']:.3f}</td><td>{r['平仓价格']:.3f}</td>
  351. <td class="{_pnl_cls(orig)}">{orig:+,.0f}元</td>
  352. <td>{_score_tag(r.get('评分',0))}</td>
  353. <td>{r.get('仓位乘数',1.0):.1f}x</td>
  354. <td class="{_pnl_cls(pnl)}">{pnl:+,.0f}元</td>
  355. </tr>"""
  356. html += '</table>'
  357. else:
  358. html += '<p>今日暂无平仓操作。</p>'
  359. # ── 整体绩效(Version F) ─────────────────────────────────────
  360. html += f"""
  361. <h2>📊 近 {REPORT_DAYS} 天 Version F 绩效(死亡区过滤 + 评分≥{SCORE_THRESH} + 加仓规则)</h2>
  362. <table>
  363. <tr><th>指标</th><th>数值</th></tr>
  364. <tr><td>参与交易</td><td>{total_n} 笔</td></tr>
  365. <tr><td>胜率</td><td>{wr:.1f}%</td></tr>
  366. <tr><td>盈亏比</td><td>{plr:.2f}</td></tr>
  367. <tr><td>总盈亏</td><td class="{_pnl_cls(total_pnl)}">{total_pnl:+,.0f} 元</td></tr>
  368. <tr><td>总收益率</td><td class="{_pnl_cls(total_ret)}">{total_ret:+.2f}%</td></tr>
  369. <tr><td>最终资金</td><td>{vF_cap:,.0f} 元</td></tr>
  370. <tr><td>最大回撤</td><td class="neg">{max_dd:.1f}%</td></tr>
  371. </table>
  372. """
  373. # ── 年度明细 ──────────────────────────────────────────────────
  374. if len(vF):
  375. html += '<h2>📅 年度明细(Version F)</h2><table>'
  376. html += '<tr><th>年份</th><th>笔数</th><th>胜率</th><th>总盈亏</th><th>年收益率</th><th>期末资金</th></tr>'
  377. vF['_year'] = pd.to_datetime(vF['开仓时间']).dt.year
  378. prev = INITIAL_CAPITAL
  379. for y in sorted(vF['_year'].unique()):
  380. sy = vF[vF['_year'] == y]
  381. pnl = sy['实际盈亏'].sum()
  382. wr_y = (sy['实际盈亏'] > 0).mean() * 100
  383. end = sy['资金余额'].iloc[-1]
  384. ret_y = pnl / prev * 100
  385. html += (f'<tr><td>{y}</td><td>{len(sy)}</td>'
  386. f'<td>{wr_y:.1f}%</td>'
  387. f'<td class="{_pnl_cls(pnl)}">{pnl:+,.0f}</td>'
  388. f'<td class="{_pnl_cls(ret_y)}">{ret_y:+.2f}%</td>'
  389. f'<td>{end:,.0f}</td></tr>')
  390. prev = end
  391. html += '</table>'
  392. # ── 最近 20 笔(Version F) ────────────────────────────────────
  393. html += '<h2>📝 最近 20 笔交易(Version F 过滤后)</h2>'
  394. if len(vF):
  395. html += """<table>
  396. <tr><th>#</th><th>开仓时间</th><th>平仓时间</th><th>开仓价</th><th>平仓价</th>
  397. <th>市场状态</th><th>评分</th><th>仓位</th><th>盈亏</th><th>实际盈亏</th><th>资金余额</th></tr>"""
  398. recent = vF.tail(20).reset_index(drop=True)
  399. for i, r in recent.iterrows():
  400. orig = r['盈亏金额']
  401. pnl = r['实际盈亏']
  402. html += (f'<tr><td>{i+1}</td>'
  403. f'<td>{str(r["开仓时间"])[:16]}</td>'
  404. f'<td>{str(r["平仓时间"])[:16]}</td>'
  405. f'<td>{r["开仓价格"]:.3f}</td><td>{r["平仓价格"]:.3f}</td>'
  406. f'<td>{_regime_tag(r.get("市场状态","—"))}</td>'
  407. f'<td>{_score_tag(r.get("评分",0))}</td>'
  408. f'<td>{r.get("仓位乘数",1.0):.1f}x</td>'
  409. f'<td class="{_pnl_cls(orig)}">{orig:+,.0f}</td>'
  410. f'<td class="{_pnl_cls(pnl)}">{pnl:+,.0f}</td>'
  411. f'<td>{r["资金余额"]:,.0f}</td></tr>')
  412. html += '</table>'
  413. else:
  414. html += '<p>暂无符合条件的交易记录。</p>'
  415. html += '</body></html>'
  416. # ── 纯文本摘要 ────────────────────────────────────────────────
  417. text = f"""CYB50 T+1 舒适区交易信号报告
  418. 生成时间: {now_str}
  419. 数据来源: 在线实时 | 拉取{FETCH_DAYS}天K线 | 报告统计近{REPORT_DAYS}天
  420. 【当前市场状态】
  421. 市场状态: {snapshot['market_regime']} {'⚠ 死亡区' if snapshot['dead_zone'] else ''}
  422. 波动率: {snapshot['vol_level']} (分位 {snapshot['vol_pct']:.2f})
  423. RSI: {snapshot['rsi_zone']} ({snapshot['rsi_value']:.1f}, 分位 {snapshot['rsi_pct']:.2f})
  424. 趋势强度: {snapshot['trend_strength']:.2f}
  425. 加分评分: {snapshot['score']} {'✓ 入场' if snapshot['score'] >= SCORE_THRESH else '✗ 观望'}
  426. 组合规则: {'命中 → ×1.5' if snapshot['combo_hit'] else '未命中 → ×1.0'}
  427. 【入场建议】{snapshot['advice']}
  428. 【今日信号】
  429. 新开仓: {len(opened_today)} 笔
  430. 平仓: {len(closing_today)} 笔
  431. 【Version F 整体绩效】
  432. 总收益率: {total_ret:+.2f}% 胜率: {wr:.1f}% 盈亏比: {plr:.2f}
  433. 总盈亏: {total_pnl:+,.0f}元 最大回撤: {max_dd:.1f}%
  434. """
  435. return html, text
  436. # ══════════════════════════════════════════════════════════════════
  437. # 邮件发送
  438. # ══════════════════════════════════════════════════════════════════
  439. def send_email(subject, html_content, text_content=''):
  440. try:
  441. msg = MIMEMultipart('alternative')
  442. msg['Subject'] = Header(subject, 'utf-8')
  443. msg['From'] = EMAIL_CONFIG['sender_email']
  444. msg['To'] = ', '.join(EMAIL_CONFIG['receiver_emails'])
  445. msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
  446. msg.attach(MIMEText(html_content, 'html', 'utf-8'))
  447. with smtplib.SMTP(EMAIL_CONFIG['smtp_server'], EMAIL_CONFIG['smtp_port']) as s:
  448. s.sendmail(EMAIL_CONFIG['sender_email'],
  449. EMAIL_CONFIG['receiver_emails'],
  450. msg.as_string())
  451. print(f'✅ 邮件发送成功: {subject}')
  452. return True
  453. except Exception as e:
  454. print(f'❌ 邮件发送失败: {e}')
  455. return False
  456. # ══════════════════════════════════════════════════════════════════
  457. # 主流程
  458. # ══════════════════════════════════════════════════════════════════
  459. def main():
  460. print('=' * 72)
  461. print(' CYB50 T+1 舒适区交易信号报告系统')
  462. print(f' 执行时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
  463. print('=' * 72)
  464. # ── 步骤1:在线获取数据 ──────────────────────────────────────
  465. config_manager = ConfigManager('config.json')
  466. fetcher = IntradayDataFetcher(config_manager)
  467. try:
  468. raw_data = fetch_data(fetcher)
  469. except Exception as e:
  470. print(f'❌ 数据获取失败: {e}')
  471. return
  472. if raw_data is None or len(raw_data) < 50:
  473. print('❌ 数据不足,退出')
  474. return
  475. # ── 步骤2:计算技术指标 ──────────────────────────────────────
  476. print('\n📈 步骤2: 计算技术指标并运行多空双向策略...')
  477. data_with_ind = fetcher.calculate_intraday_indicators(raw_data)
  478. signal_gen = DualDirectionSignalGenerator()
  479. signals_df = signal_gen.generate_dual_direction_signals(data_with_ind)
  480. executor = DualDirectionExecutor(initial_capital=INITIAL_CAPITAL)
  481. _, trades_df = executor.execute_dual_direction_trades(signals_df)
  482. long_trades = trades_df[trades_df['交易方向'] == '做多'].copy()
  483. print(f' 做多交易: {len(long_trades)} 笔')
  484. # ── 步骤3:T+1 规则转换 ──────────────────────────────────────
  485. print('\n🔄 步骤3: T+1 规则转换...')
  486. t1_trades = simulate_t1_trades(data_with_ind, long_trades, INITIAL_CAPITAL)
  487. print(f' T+1 交易记录: {len(t1_trades)} 笔')
  488. # ── 步骤4:计算市场环境 ──────────────────────────────────────
  489. print('\n🔬 步骤4: 计算市场环境指标...')
  490. market_analyzer = MarketEnvironmentAnalyzer(data_with_ind)
  491. cza = ComfortZoneAnalyzer(t1_trades, market_analyzer)
  492. cza._enrich_trades_with_environment() # 结果存入 cza.enriched_trades
  493. enriched = cza.enriched_trades
  494. # ── 步骤5:应用 Version F 过滤 ───────────────────────────────
  495. print('\n🎯 步骤5: 应用 Version F 策略过滤...')
  496. labeled = add_strategy_labels(enriched)
  497. total = len(labeled)
  498. passed = labeled['入场有效'].sum()
  499. dead = labeled['死亡区'].sum()
  500. low_sc = (~labeled['死亡区'] & (labeled['评分'] < SCORE_THRESH)).sum()
  501. combo = (labeled['入场有效'] & labeled['组合命中']).sum()
  502. print(f' 全量: {total}笔 | 死亡区过滤: {dead}笔 | 低分过滤: {low_sc}笔')
  503. print(f' 入场有效: {passed}笔 | 其中加仓×{BOOST_MULT}: {combo}笔')
  504. # ── 步骤6:今日信号分析 ──────────────────────────────────────
  505. print('\n📅 步骤6: 分析今日信号...')
  506. opened_today, closing_today, snapshot = analyse_today(
  507. labeled, data_with_ind, market_analyzer, cza
  508. )
  509. print(f' 今日开仓: {len(opened_today)}笔 今日平仓: {len(closing_today)}笔')
  510. print(f' 当前市场: {snapshot["market_regime"]} 评分: {snapshot["score"]} 建议: {snapshot["advice"]}')
  511. # ── 步骤7:生成报告 ──────────────────────────────────────────
  512. print('\n📝 步骤7: 生成 HTML 报告...')
  513. html_report, text_report = generate_report(labeled, snapshot, opened_today, closing_today)
  514. # ── 步骤8:决定是否发送邮件 ──────────────────────────────────
  515. now = datetime.now()
  516. is_post_close = (now.hour == 15 and 0 <= now.minute <= 30)
  517. has_today = len(opened_today) > 0 or len(closing_today) > 0
  518. should_send = has_today or is_post_close
  519. cutoff = datetime.now() - timedelta(days=REPORT_DAYS)
  520. vF_trades = labeled[
  521. labeled['入场有效'] &
  522. (pd.to_datetime(labeled['开仓时间']) >= cutoff)
  523. ]
  524. vF_pnl = vF_trades['实际盈亏'].sum() if len(vF_trades) else 0
  525. vF_ret = vF_pnl / INITIAL_CAPITAL * 100
  526. subject = (
  527. f'📡 CYB50-T1信号 {now.strftime("%m-%d %H:%M")} | '
  528. f'{snapshot["market_regime"]} | '
  529. f'评分{snapshot["score"]} | '
  530. f'建议:{snapshot["advice"][:6]} | '
  531. f'累计收益{vF_ret:+.1f}%'
  532. )
  533. print(f'\n📧 步骤8: 发送邮件...')
  534. if should_send:
  535. send_email(subject, html_report, text_report)
  536. else:
  537. print(' 当天无交易且非盘后时间,跳过发送')
  538. print(f' 邮件主题预览: {subject}')
  539. # ── 本地保存报告 ─────────────────────────────────────────────
  540. ts = now.strftime('%Y%m%d_%H%M%S')
  541. rpt_path = os.path.join(os.path.dirname(__file__), f'signal_report_{ts}.html')
  542. with open(rpt_path, 'w', encoding='utf-8') as f:
  543. f.write(html_report)
  544. print(f' 报告已保存: {os.path.basename(rpt_path)}')
  545. print('\n' + '=' * 72)
  546. print(' 完成')
  547. print('=' * 72)
  548. if __name__ == '__main__':
  549. main()