trend_report_fixed.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 创业板50指数 - 实时交易报告系统 (修复数据连续性)
  5. 支持新浪、腾讯、网易多数据源
  6. """
  7. import sys
  8. sys.path.insert(0, '/root/.openclaw/workspace/cat-fly')
  9. sys.path.insert(0, '/root/.openclaw/workspace/quant')
  10. import pandas as pd
  11. import numpy as np
  12. from datetime import datetime, timedelta
  13. import smtplib
  14. import ssl
  15. from email.mime.text import MIMEText
  16. from email.mime.multipart import MIMEMultipart
  17. from email.header import Header
  18. import warnings
  19. import requests
  20. import json
  21. import time
  22. warnings.filterwarnings('ignore')
  23. # ==================== 邮件配置 ====================
  24. EMAIL_CONFIG = {
  25. "smtp_server": "localhost",
  26. "smtp_port": 25,
  27. "sender_email": "catfly@erwin.wang",
  28. "receiver_email": "380880504@qq.com"
  29. }
  30. # ==================== 多数据源获取 ====================
  31. def fetch_sina_realtime():
  32. """新浪实时行情接口"""
  33. try:
  34. url = "https://hq.sinajs.cn/list=sz399673"
  35. headers = {
  36. 'Referer': 'https://finance.sina.com.cn',
  37. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
  38. }
  39. response = requests.get(url, headers=headers, timeout=10)
  40. response.encoding = 'gb2312'
  41. data_str = response.text
  42. if 'var hq_str_sz399673="' in data_str:
  43. data_str = data_str.split('var hq_str_sz399673="')[1].split('"')[0]
  44. parts = data_str.split(',')
  45. if len(parts) >= 32:
  46. # 解析日期时间
  47. date_str = parts[30].strip() # YYYYMMDD
  48. time_str = parts[31].strip() if len(parts) > 31 else ""
  49. if len(date_str) == 8 and date_str.isdigit():
  50. date = pd.Timestamp(f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}")
  51. else:
  52. date = pd.Timestamp.now().normalize()
  53. return {
  54. 'open': float(parts[1]),
  55. 'high': float(parts[4]),
  56. 'low': float(parts[5]),
  57. 'close': float(parts[3]),
  58. 'volume': int(parts[8]),
  59. 'name': parts[0],
  60. 'date': date,
  61. 'time': time_str
  62. }
  63. except Exception as e:
  64. print(f"新浪实时数据获取失败: {e}")
  65. return None
  66. def fetch_tencent_realtime():
  67. """腾讯实时行情接口"""
  68. try:
  69. url = "https://qt.gtimg.cn/q=sz399673"
  70. headers = {
  71. 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
  72. }
  73. response = requests.get(url, headers=headers, timeout=10)
  74. response.encoding = 'gb2312'
  75. data_str = response.text
  76. if 'v_sz399673="' in data_str:
  77. data_str = data_str.split('v_sz399673="')[1].split('"')[0]
  78. parts = data_str.split('~')
  79. if len(parts) >= 45:
  80. # 日期格式: YYYY-MM-DD
  81. date_str = parts[30]
  82. date = pd.Timestamp(date_str)
  83. return {
  84. 'name': parts[1],
  85. 'close': float(parts[3]),
  86. 'open': float(parts[5]),
  87. 'high': float(parts[33]),
  88. 'low': float(parts[34]),
  89. 'volume': int(parts[36]),
  90. 'date': date
  91. }
  92. except Exception as e:
  93. print(f"腾讯实时数据获取失败: {e}")
  94. return None
  95. def fetch_akshare_hist(start_date, end_date):
  96. """使用akshare获取历史数据"""
  97. try:
  98. import akshare as ak
  99. print(f" 使用akshare获取 {start_date} 到 {end_date} 的历史数据...")
  100. # 获取创业板50历史行情
  101. df = ak.index_zh_a_hist(symbol="399673", period="daily",
  102. start_date=start_date, end_date=end_date)
  103. if df is not None and len(df) > 0:
  104. data_list = []
  105. for _, row in df.iterrows():
  106. data_list.append({
  107. 'date': pd.Timestamp(row['日期']),
  108. 'open': float(row['开盘']),
  109. 'high': float(row['最高']),
  110. 'low': float(row['最低']),
  111. 'close': float(row['收盘']),
  112. 'volume': int(row['成交量'])
  113. })
  114. return data_list
  115. except Exception as e:
  116. print(f" akshare获取失败: {e}")
  117. return None
  118. def fetch_baostock_hist(start_date, end_date):
  119. """使用baostock获取历史数据"""
  120. try:
  121. import baostock as bs
  122. print(f" 使用baostock获取 {start_date} 到 {end_date} 的历史数据...")
  123. lg = bs.login()
  124. if lg.error_code != '0':
  125. print(f" baostock登录失败: {lg.error_msg}")
  126. return None
  127. # 创业板50代码: sz.399673
  128. rs = bs.query_history_k_data_plus("sz.399673",
  129. "date,open,high,low,close,volume",
  130. start_date=start_date, end_date=end_date,
  131. frequency="d", adjustflag="3")
  132. data_list = []
  133. while (rs.error_code == '0') & rs.next():
  134. row = rs.get_row_data()
  135. if row[0]: # date
  136. data_list.append({
  137. 'date': pd.Timestamp(row[0]),
  138. 'open': float(row[1]) if row[1] else 0,
  139. 'high': float(row[2]) if row[2] else 0,
  140. 'low': float(row[3]) if row[3] else 0,
  141. 'close': float(row[4]) if row[4] else 0,
  142. 'volume': int(float(row[5])) if row[5] else 0
  143. })
  144. bs.logout()
  145. if data_list:
  146. return data_list
  147. except Exception as e:
  148. print(f" baostock获取失败: {e}")
  149. return None
  150. def fetch_missing_data(last_date, today):
  151. """获取缺失的历史数据"""
  152. if last_date >= today:
  153. return []
  154. start_str = (last_date + timedelta(days=1)).strftime('%Y-%m-%d')
  155. end_str = (today - timedelta(days=1)).strftime('%Y-%m-%d')
  156. print(f"\n📊 需要补全数据: {start_str} 到 {end_str}")
  157. # 尝试akshare
  158. data = fetch_akshare_hist(start_str.replace('-', ''), end_str.replace('-', ''))
  159. if data:
  160. print(f" ✅ akshare获取成功: {len(data)} 条")
  161. return data
  162. # 尝试baostock
  163. data = fetch_baostock_hist(start_str, end_str)
  164. if data:
  165. print(f" ✅ baostock获取成功: {len(data)} 条")
  166. return data
  167. print(" ⚠️ 无法获取缺失数据,将仅使用实时数据")
  168. return []
  169. def fetch_realtime_data():
  170. """获取实时数据,尝试多个数据源"""
  171. print("尝试获取实时数据...")
  172. # 尝试新浪实时
  173. print(" 尝试新浪实时接口...")
  174. data = fetch_sina_realtime()
  175. if data:
  176. print(f" ✅ 新浪实时数据获取成功")
  177. return data, 'sina_realtime'
  178. # 尝试腾讯实时
  179. print(" 尝试腾讯实时接口...")
  180. data = fetch_tencent_realtime()
  181. if data:
  182. print(f" ✅ 腾讯实时数据获取成功")
  183. return data, 'tencent_realtime'
  184. print(" ❌ 所有数据源均失败")
  185. return None, None
  186. # ==================== 趋势跟踪策略 ====================
  187. class TrendTrackingStrategy:
  188. """趋势跟踪策略 - 修复数据连续性版"""
  189. def __init__(self):
  190. self.data = None
  191. self.data_gaps = [] # 记录数据缺口
  192. def load_and_merge_data(self, csv_file='cyb50_baostock.csv'):
  193. """加载历史数据并合并实时数据"""
  194. try:
  195. # 加载历史数据
  196. df = pd.read_csv(f'/root/.openclaw/workspace/quant/{csv_file}')
  197. df['date'] = pd.to_datetime(df['date'])
  198. df = df.set_index('date').sort_index()
  199. # 转换数据类型
  200. for col in ['open', 'high', 'low', 'close', 'volume']:
  201. df[col] = pd.to_numeric(df[col], errors='coerce')
  202. # 获取最新历史数据日期
  203. last_hist_date = df.index[-1]
  204. today = pd.Timestamp.now().normalize()
  205. print(f"历史数据最新日期: {last_hist_date.date()}")
  206. print(f"当前日期: {today.date()}")
  207. # 检查数据缺口
  208. missing_data = []
  209. if last_hist_date < today - timedelta(days=1):
  210. # 尝试获取缺失的历史数据
  211. missing_data = fetch_missing_data(last_hist_date, today)
  212. if missing_data:
  213. # 合并缺失数据
  214. for item in missing_data:
  215. new_row = pd.DataFrame({
  216. 'open': [item['open']],
  217. 'high': [item['high']],
  218. 'low': [item['low']],
  219. 'close': [item['close']],
  220. 'volume': [item['volume']]
  221. }, index=[item['date']])
  222. df = pd.concat([df, new_row])
  223. df = df.sort_index()
  224. last_hist_date = df.index[-1]
  225. print(f"✅ 已合并缺失数据,最新历史日期: {last_hist_date.date()}")
  226. else:
  227. # 记录数据缺口
  228. gap_start = last_hist_date + timedelta(days=1)
  229. gap_end = today - timedelta(days=1)
  230. self.data_gaps.append(f"{gap_start.date()} 到 {gap_end.date()}")
  231. print(f"⚠️ 数据缺口: {gap_start.date()} 到 {gap_end.date()}")
  232. # 获取今天的实时数据
  233. if last_hist_date < today:
  234. realtime_data, source = fetch_realtime_data()
  235. if realtime_data:
  236. date = realtime_data['date']
  237. # 检查是否已有该日期数据
  238. if date > last_hist_date:
  239. new_row = pd.DataFrame({
  240. 'open': [realtime_data['open']],
  241. 'high': [realtime_data['high']],
  242. 'low': [realtime_data['low']],
  243. 'close': [realtime_data['close']],
  244. 'volume': [realtime_data['volume']]
  245. }, index=[date])
  246. df = pd.concat([df, new_row])
  247. print(f"✅ 已合并{source}数据: {date.date()} 收盘价 {realtime_data['close']}")
  248. else:
  249. print(f"⚠️ 获取的数据日期({date.date()})不大于历史最新日期")
  250. else:
  251. print("⚠️ 未获取到实时数据")
  252. else:
  253. print("✅ 历史数据已是最新")
  254. self.data = df.sort_index()
  255. print(f"\n数据范围: {self.data.index[0].date()} ~ {self.data.index[-1].date()}")
  256. print(f"数据条数: {len(self.data)}")
  257. if self.data_gaps:
  258. print(f"⚠️ 警告: 存在数据缺口: {', '.join(self.data_gaps)}")
  259. return True
  260. except Exception as e:
  261. print(f"数据加载失败: {e}")
  262. import traceback
  263. traceback.print_exc()
  264. return False
  265. def calculate_indicators(self):
  266. """计算技术指标"""
  267. df = self.data.copy()
  268. # 均线
  269. df['ma10'] = df['close'].rolling(window=10, min_periods=1).mean()
  270. df['ma30'] = df['close'].rolling(window=30, min_periods=1).mean()
  271. # 20日高低点
  272. df['high_20'] = df['high'].rolling(window=20).max()
  273. df['low_20'] = df['low'].rolling(window=20).min()
  274. # 10日涨幅
  275. df['ret_10'] = df['close'].pct_change(periods=10)
  276. # ATR
  277. tr1 = df['high'] - df['low']
  278. tr2 = abs(df['high'] - df['close'].shift(1))
  279. tr3 = abs(df['low'] - df['close'].shift(1))
  280. df['tr'] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
  281. df['atr'] = df['tr'].rolling(window=20).mean()
  282. self.data = df
  283. return df
  284. def generate_signals(self):
  285. """生成交易信号"""
  286. df = self.data
  287. # 买入条件
  288. buy_cond = (
  289. (df['close'] > df['ma10']) &
  290. (df['ma10'] > df['ma30']) &
  291. (df['close'] >= df['high_20'] * 0.995) &
  292. (df['ret_10'] > 0.02)
  293. )
  294. # 卖出条件
  295. sell_cond = (
  296. (df['close'] < df['ma30']) |
  297. (df['close'] <= df['low_20'] * 1.005)
  298. )
  299. df['signal'] = 0
  300. df.loc[buy_cond, 'signal'] = 1
  301. df.loc[sell_cond, 'signal'] = -1
  302. return df
  303. def backtest(self, initial_capital=1000000):
  304. """回测计算"""
  305. df = self.generate_signals()
  306. position = 0
  307. entry_price = 0
  308. peak_price = 0
  309. capital = initial_capital
  310. trades = []
  311. for i in range(30, len(df)):
  312. date = df.index[i]
  313. price = df['close'].iloc[i]
  314. signal = df['signal'].iloc[i]
  315. # 移动止损检查
  316. if position > 0:
  317. if price > peak_price:
  318. peak_price = price
  319. if price < peak_price * 0.90:
  320. signal = -1
  321. # 买入
  322. if signal == 1 and position == 0:
  323. position = 1
  324. entry_price = price
  325. peak_price = price
  326. trades.append({
  327. 'date': date,
  328. 'action': 'BUY',
  329. 'price': price,
  330. 'capital': capital
  331. })
  332. # 卖出
  333. elif signal == -1 and position == 1:
  334. pnl = (price / entry_price - 1) * capital
  335. capital += pnl
  336. position = 0
  337. trades.append({
  338. 'date': date,
  339. 'action': 'SELL',
  340. 'price': price,
  341. 'capital': capital,
  342. 'pnl': pnl,
  343. 'return_pct': (price / entry_price - 1) * 100
  344. })
  345. current_position = position
  346. current_price = df['close'].iloc[-1]
  347. if position == 1:
  348. unrealized_pnl = (current_price / entry_price - 1) * capital
  349. total_value = capital + unrealized_pnl
  350. else:
  351. total_value = capital
  352. total_return = (total_value / initial_capital - 1) * 100
  353. return {
  354. 'trades': trades,
  355. 'current_position': current_position,
  356. 'current_price': current_price,
  357. 'entry_price': entry_price if position == 1 else None,
  358. 'capital': capital,
  359. 'total_value': total_value,
  360. 'total_return': total_return,
  361. 'trade_count': len([t for t in trades if t['action'] == 'SELL']),
  362. 'data_end_date': df.index[-1].strftime('%Y-%m-%d'),
  363. 'data_gaps': self.data_gaps
  364. }
  365. def get_recent_indicators(self, days=20):
  366. """获取近N天指标详情"""
  367. df = self.data.tail(days).copy()
  368. indicators = []
  369. for date, row in df.iterrows():
  370. indicators.append({
  371. 'date': date.strftime('%Y-%m-%d'),
  372. 'close': round(row['close'], 2),
  373. 'ma10': round(row['ma10'], 2) if not pd.isna(row['ma10']) else '-',
  374. 'ma30': round(row['ma30'], 2) if not pd.isna(row['ma30']) else '-',
  375. 'high_20': round(row['high_20'], 2) if not pd.isna(row['high_20']) else '-',
  376. 'ret_10': f"{row['ret_10']*100:.2f}%" if not pd.isna(row['ret_10']) else '-',
  377. 'signal': '买入' if row['signal'] == 1 else ('卖出' if row['signal'] == -1 else '持有'),
  378. 'atr': round(row['atr'], 2) if not pd.isna(row['atr']) else '-'
  379. })
  380. return indicators
  381. def get_recent_trades(self, n=20):
  382. """获取近N次交易详情"""
  383. result = self.backtest()
  384. trades = result['trades'][-n:]
  385. return trades
  386. def generate_report():
  387. """生成完整报告"""
  388. strategy = TrendTrackingStrategy()
  389. if not strategy.load_and_merge_data():
  390. return None, None, None
  391. strategy.calculate_indicators()
  392. result = strategy.backtest()
  393. recent_indicators = strategy.get_recent_indicators(20)
  394. recent_trades = strategy.get_recent_trades(20)
  395. # 数据缺口警告
  396. gap_warning = ""
  397. if result['data_gaps']:
  398. gap_warning = f"<div class='warning'>⚠️ 数据缺口: {', '.join(result['data_gaps'])} - 指标计算可能不准确</div>"
  399. html = f"""
  400. <html>
  401. <head>
  402. <meta charset="utf-8">
  403. <style>
  404. body {{ font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }}
  405. .container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
  406. h1 {{ color: #333; border-bottom: 3px solid #007bff; padding-bottom: 10px; }}
  407. h2 {{ color: #555; margin-top: 30px; border-left: 4px solid #007bff; padding-left: 10px; }}
  408. .summary {{ background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; }}
  409. .metric {{ display: inline-block; margin: 10px 20px 10px 0; }}
  410. .metric-label {{ color: #666; font-size: 12px; }}
  411. .metric-value {{ font-size: 24px; font-weight: bold; color: #333; }}
  412. .positive {{ color: #28a745; }}
  413. .negative {{ color: #dc3545; }}
  414. .warning {{ background: #fff3cd; color: #856404; padding: 10px; border-radius: 5px; margin: 10px 0; }}
  415. table {{ width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13px; }}
  416. th {{ background: #007bff; color: white; padding: 10px; text-align: left; }}
  417. td {{ padding: 8px 10px; border-bottom: 1px solid #ddd; }}
  418. tr:nth-child(even) {{ background: #f8f9fa; }}
  419. tr:hover {{ background: #e9ecef; }}
  420. .position-yes {{ color: #28a745; font-weight: bold; }}
  421. .position-no {{ color: #666; }}
  422. .buy {{ color: #28a745; font-weight: bold; }}
  423. .sell {{ color: #dc3545; font-weight: bold; }}
  424. .data-info {{ background: #e7f3ff; padding: 10px; border-radius: 5px; margin: 10px 0; color: #004085; }}
  425. </style>
  426. </head>
  427. <body>
  428. <div class="container">
  429. <h1>🚀 创业板50趋势跟踪策略报告</h1>
  430. <p style="color: #666;">生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
  431. {gap_warning}
  432. <div class="data-info">
  433. <strong>数据更新:</strong> 最新数据日期: {result['data_end_date']} |
  434. 数据范围: {strategy.data.index[0].date()} ~ {strategy.data.index[-1].date()}
  435. </div>
  436. <div class="summary">
  437. <h2>📊 总体绩效</h2>
  438. <div class="metric">
  439. <div class="metric-label">当前持仓</div>
  440. <div class="metric-value {'position-yes' if result['current_position'] == 1 else 'position-no'}">
  441. {'持有中' if result['current_position'] == 1 else '空仓'}
  442. </div>
  443. </div>
  444. <div class="metric">
  445. <div class="metric-label">当前价格</div>
  446. <div class="metric-value">{result['current_price']:.2f}</div>
  447. </div>
  448. <div class="metric">
  449. <div class="metric-label">累计收益率</div>
  450. <div class="metric-value {'positive' if result['total_return'] >= 0 else 'negative'}">
  451. {result['total_return']:+.2f}%
  452. </div>
  453. </div>
  454. <div class="metric">
  455. <div class="metric-label">总资产</div>
  456. <div class="metric-value">{result['total_value']:,.0f}元</div>
  457. </div>
  458. <div class="metric">
  459. <div class="metric-label">交易次数</div>
  460. <div class="metric-value">{result['trade_count']}</div>
  461. </div>
  462. </div>
  463. """
  464. if result['current_position'] == 1:
  465. unrealized = (result['current_price'] / result['entry_price'] - 1) * 100
  466. html += f"""
  467. <div class="summary" style="background: #e8f5e9;">
  468. <h2>📈 持仓详情</h2>
  469. <p><strong>入场价格:</strong> {result['entry_price']:.2f} 元</p>
  470. <p><strong>当前浮盈:</strong> <span class="{'positive' if unrealized >= 0 else 'negative'}">{unrealized:+.2f}%</span></p>
  471. </div>
  472. """
  473. # 近20天指标
  474. html += """
  475. <h2>📅 近20天指标详情</h2>
  476. <table>
  477. <tr>
  478. <th>日期</th>
  479. <th>收盘价</th>
  480. <th>MA10</th>
  481. <th>MA30</th>
  482. <th>20日高</th>
  483. <th>10日涨幅</th>
  484. <th>信号</th>
  485. <th>ATR</th>
  486. </tr>
  487. """
  488. for ind in recent_indicators:
  489. signal_class = 'buy' if ind['signal'] == '买入' else ('sell' if ind['signal'] == '卖出' else '')
  490. html += f"""
  491. <tr>
  492. <td>{ind['date']}</td>
  493. <td>{ind['close']}</td>
  494. <td>{ind['ma10']}</td>
  495. <td>{ind['ma30']}</td>
  496. <td>{ind['high_20']}</td>
  497. <td>{ind['ret_10']}</td>
  498. <td class="{signal_class}">{ind['signal']}</td>
  499. <td>{ind['atr']}</td>
  500. </tr>
  501. """
  502. html += """
  503. </table>
  504. <h2>💼 近20次交易详情</h2>
  505. <table>
  506. <tr>
  507. <th>日期</th>
  508. <th>操作</th>
  509. <th>价格</th>
  510. <th>盈亏金额</th>
  511. <th>盈亏比例</th>
  512. <th>总资产</th>
  513. </tr>
  514. """
  515. for trade in recent_trades:
  516. action_class = 'buy' if trade['action'] == 'BUY' else 'sell'
  517. pnl = trade.get('pnl', 0)
  518. ret = trade.get('return_pct', 0)
  519. has_pnl = 'pnl' in trade
  520. date_str = trade['date'].strftime('%Y-%m-%d') if hasattr(trade['date'], 'strftime') else str(trade['date'])
  521. # 盈亏比例样式:买入不显示样式,卖出根据盈亏显示
  522. ret_class = '' if not has_pnl else ('positive' if ret >= 0 else 'negative')
  523. ret_text = '-' if not has_pnl else f'{ret:+.2f}%'
  524. html += f"""
  525. <tr>
  526. <td>{date_str}</td>
  527. <td class="{action_class}">{trade['action']}</td>
  528. <td>{trade['price']:.2f}</td>
  529. <td>{f'{pnl:+,.0f}元' if has_pnl else '-'}</td>
  530. <td class="{ret_class}">{ret_text}</td>
  531. <td>{trade['capital']:,.0f}元</td>
  532. </tr>
  533. """
  534. html += """
  535. </table>
  536. <div style="margin-top: 30px; padding: 15px; background: #fff3cd; border-radius: 5px; color: #856404;">
  537. <strong>策略说明:</strong><br>
  538. • 买入条件: 价格>MA10>MA30 且 突破20日高×0.995 且 10日涨幅>2%<br>
  539. • 卖出条件: 跌破MA30 或 创20日新低 或 回撤10%止损<br>
  540. • 数据已自动合并历史数据与实时数据
  541. </div>
  542. </div>
  543. </body>
  544. </html>
  545. """
  546. # 文本报告
  547. text = f"""
  548. 创业板50趋势跟踪策略报告
  549. 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
  550. 数据最新日期: {result['data_end_date']}
  551. """
  552. if result['data_gaps']:
  553. text += f"⚠️ 警告: 数据缺口 {', '.join(result['data_gaps'])}\n"
  554. text += f"""
  555. 【总体绩效】
  556. 当前持仓: {'持有中' if result['current_position'] == 1 else '空仓'}
  557. 当前价格: {result['current_price']:.2f}元
  558. 累计收益率: {result['total_return']:+.2f}%
  559. 总资产: {result['total_value']:,.0f}元
  560. 【近20天指标】
  561. 日期 收盘价 MA10 MA30 20日高 10日涨幅 信号
  562. """
  563. for ind in recent_indicators:
  564. text += f"{ind['date']} {ind['close']:>8} {ind['ma10']:>8} {ind['ma30']:>8} {ind['high_20']:>8} {ind['ret_10']:>8} {ind['signal']:>4}\n"
  565. text += "\n【近20次交易】\n"
  566. for trade in recent_trades:
  567. pnl_str = f"{trade.get('pnl', 0):+,.0f}元" if 'pnl' in trade else '-'
  568. ret_str = f"{trade.get('return_pct', 0):+.2f}%" if 'return_pct' in trade else '-'
  569. date_str = trade['date'].strftime('%Y-%m-%d') if hasattr(trade['date'], 'strftime') else str(trade['date'])
  570. text += f"{date_str} {trade['action']:>6} {trade['price']:>8.2f} {pnl_str:>12} {ret_str:>10}\n"
  571. return html, text, result
  572. def send_email(subject, html_content, text_content):
  573. """发送邮件"""
  574. try:
  575. msg = MIMEMultipart('alternative')
  576. msg['Subject'] = Header(subject, 'utf-8')
  577. msg['From'] = EMAIL_CONFIG['sender_email']
  578. msg['To'] = EMAIL_CONFIG['receiver_email']
  579. text_part = MIMEText(text_content, 'plain', 'utf-8')
  580. msg.attach(text_part)
  581. html_part = MIMEText(html_content, 'html', 'utf-8')
  582. msg.attach(html_part)
  583. with smtplib.SMTP(EMAIL_CONFIG['smtp_server'], EMAIL_CONFIG['smtp_port']) as server:
  584. server.sendmail(
  585. EMAIL_CONFIG['sender_email'],
  586. EMAIL_CONFIG['receiver_email'],
  587. msg.as_string()
  588. )
  589. print(f"✅ 邮件发送成功: {subject}")
  590. return True
  591. except Exception as e:
  592. print(f"❌ 邮件发送失败: {e}")
  593. return False
  594. def main():
  595. """主程序"""
  596. print("="*60)
  597. print("🚀 创业板50趋势跟踪实时报告 (修复数据连续性)")
  598. print("="*60)
  599. print(f"执行时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  600. html, text, result = generate_report()
  601. if html is None:
  602. print("❌ 报告生成失败")
  603. return
  604. print(f"\n✅ 报告生成完成")
  605. print(f" 数据最新日期: {result['data_end_date']}")
  606. print(f" 当前持仓: {'持有中' if result['current_position'] == 1 else '空仓'}")
  607. print(f" 累计收益: {result['total_return']:+.2f}%")
  608. print("\n📧 发送邮件...")
  609. position_status = "持仓" if result['current_position'] == 1 else "空仓"
  610. subject = f"🚀 创业板50趋势报告 {datetime.now().strftime('%m-%d %H:%M')} | {position_status} | 收益{result['total_return']:+.2f}%"
  611. send_email(subject, html, text)
  612. print("\n✅ 全部完成!")
  613. print("="*60)
  614. if __name__ == "__main__":
  615. main()