trend_report_multi_source.py 30 KB

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