gen_html_emails.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import importlib.util
  5. import math
  6. import subprocess
  7. import sys
  8. from datetime import datetime, time
  9. from pathlib import Path
  10. from zoneinfo import ZoneInfo
  11. import pandas as pd
  12. from fetch_data import fetch_chinext50_data
  13. WORKDIR = Path(__file__).resolve().parent
  14. DEFAULT_EMAIL = "wangshuai.vip@qq.com"
  15. DEFAULT_BASELINE_CASH = 100_000
  16. DEFAULT_DISPLAY_CASH = 1_000_000
  17. SEND_TZ = ZoneInfo("Asia/Shanghai")
  18. SEND_START = time(15, 10)
  19. SEND_END = time(22, 30)
  20. def load_module(name: str, path: Path):
  21. spec = importlib.util.spec_from_file_location(name, path)
  22. mod = importlib.util.module_from_spec(spec)
  23. sys.modules[spec.name] = mod
  24. spec.loader.exec_module(mod)
  25. return mod
  26. combo_mod = load_module("combo", WORKDIR / "shortlist_combo_trials.py")
  27. exp_mod = load_module("exp", WORKDIR / "chinext50_experiments.py")
  28. bt = combo_mod.bt
  29. TRADING_DAYS = combo_mod.TRADING_DAYS
  30. BasePortfolioStrategy = combo_mod.BasePortfolioStrategy
  31. class CleanTradeRecorder(bt.Analyzer):
  32. def __init__(self):
  33. self.trades = []
  34. self._entry_cost = 0.0
  35. self._last_sell_price = None
  36. self._entry_qty = 0
  37. def notify_order(self, order):
  38. if order.status != order.Completed:
  39. return
  40. if order.isbuy():
  41. self._entry_cost += abs(order.executed.size) * order.executed.price
  42. self._entry_qty += abs(int(round(order.executed.size)))
  43. elif order.issell():
  44. self._last_sell_price = round(order.executed.price, 2)
  45. def notify_trade(self, trade):
  46. if not trade.isclosed:
  47. return
  48. pnl = round(trade.pnlcomm, 2)
  49. cost = self._entry_cost if self._entry_cost > 0 else 1e-9
  50. pnl_pct = round((pnl / cost) * 100, 2)
  51. exit_value = round(self.strategy.broker.getvalue(), 2)
  52. self.trades.append(
  53. {
  54. "entry_date": bt.num2date(trade.dtopen).strftime("%Y-%m-%d"),
  55. "exit_date": bt.num2date(trade.dtclose).strftime("%Y-%m-%d"),
  56. "entry_price": round(trade.price, 2) if trade.price else None,
  57. "exit_price": self._last_sell_price,
  58. "qty": self._entry_qty,
  59. "days": int(trade.barlen),
  60. "pnl": pnl,
  61. "pnl_pct": pnl_pct,
  62. "nav": exit_value,
  63. }
  64. )
  65. self._entry_cost = 0.0
  66. self._last_sell_price = None
  67. self._entry_qty = 0
  68. def run_with_trades(strategy_cls, df, baseline_cash: float, config: dict | None = None):
  69. cerebro = bt.Cerebro(stdstats=False)
  70. cerebro.adddata(combo_mod.Chinext50Data(dataname=df))
  71. cerebro.addstrategy(strategy_cls, **(config or {}))
  72. cerebro.broker.setcash(baseline_cash)
  73. cerebro.broker.setcommission(commission=combo_mod.COMMISSION)
  74. cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
  75. cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
  76. cerebro.addanalyzer(bt.analyzers.SharpeRatio_A, _name="sharpe", riskfreerate=0.02)
  77. cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
  78. cerebro.addanalyzer(CleanTradeRecorder, _name="recorder")
  79. strategy = cerebro.run()[0]
  80. final_value = cerebro.broker.getvalue()
  81. returns = strategy.analyzers.returns.get_analysis()
  82. drawdown = strategy.analyzers.drawdown.get_analysis()
  83. sharpe = strategy.analyzers.sharpe.get_analysis()
  84. trades = strategy.analyzers.trades.get_analysis()
  85. closed_trades = trades.get("total", {}).get("closed", 0)
  86. won_trades = trades.get("won", {}).get("total", 0)
  87. total_bars = len(df)
  88. metrics = {
  89. "final_value": round(final_value, 2),
  90. "total_return_pct": round((final_value / baseline_cash - 1.0) * 100.0, 2),
  91. "annual_return_pct": round(returns.get("rnorm100", 0.0), 2),
  92. "max_drawdown_pct": round(drawdown.get("max", {}).get("drawdown", 0.0), 2),
  93. "sharpe": round(sharpe["sharperatio"], 3) if sharpe.get("sharperatio") is not None else None,
  94. "closed_trades": closed_trades,
  95. "win_rate_pct": round((won_trades / closed_trades) * 100.0, 2) if closed_trades else 0.0,
  96. "exposure_pct": round((strategy.exposure_sum / total_bars) * 100.0, 2),
  97. }
  98. return metrics, strategy.analyzers.recorder.trades
  99. def scale_metrics(metrics: dict, scale: float) -> dict:
  100. out = dict(metrics)
  101. out["final_value"] = round(out["final_value"] * scale, 2)
  102. return out
  103. def scale_trades(trades: list[dict], scale: float) -> list[dict]:
  104. out = []
  105. for trade in trades:
  106. row = dict(trade)
  107. row["qty"] = int(round(row["qty"] * scale))
  108. row["pnl"] = round(row["pnl"] * scale, 2)
  109. row["nav"] = round(row["nav"] * scale, 2)
  110. out.append(row)
  111. return out
  112. def safe_num(v):
  113. if v is None:
  114. return "-"
  115. if isinstance(v, int):
  116. return f"{v:,}"
  117. if isinstance(v, float):
  118. s = f"{v:,.2f}"
  119. if s.endswith(".00"):
  120. s = s[:-3]
  121. return s
  122. return str(v)
  123. def td(text, extra=""):
  124. return f'<td style="border:1px solid #d9dee7;padding:8px 10px;{extra}">{text}</td>'
  125. def th(text):
  126. return f'<th style="border:1px solid #d9dee7;background:#f3f6fa;padding:8px 10px;text-align:left;">{text}</th>'
  127. def compute_subperiods(strategy_cls, df, baseline_cash: float, config: dict | None = None) -> list[dict]:
  128. periods = [
  129. ("2014-06 ~ 2018-12", "2014-06-18", "2018-12-31"),
  130. ("2019-01 ~ 2022-12", "2019-01-01", "2022-12-31"),
  131. (f"2023-01 ~ {df.index.max().strftime('%Y-%m')}", "2023-01-01", df.index.max().strftime('%Y-%m-%d')),
  132. ]
  133. out = []
  134. for label, start, end in periods:
  135. sub_df = df.loc[(df.index >= start) & (df.index <= end)].copy()
  136. if len(sub_df) < 30:
  137. continue
  138. metrics, _ = run_with_trades(strategy_cls, sub_df, baseline_cash, config)
  139. out.append(
  140. {
  141. "period": label,
  142. "annual": metrics["annual_return_pct"],
  143. "sharpe": metrics["sharpe"],
  144. "max_dd": metrics["max_drawdown_pct"],
  145. }
  146. )
  147. return out
  148. def compute_recent_dualthrust_signals(df: pd.DataFrame, range_period: int = 20, k1: float = 0.3, k2: float = 0.3, recent_n: int = 20) -> list[dict]:
  149. state = False
  150. rows = []
  151. close = df['close']
  152. for i in range(len(df)):
  153. if i <= range_period:
  154. continue
  155. window = close.iloc[i - range_period:i]
  156. thrust_range = float(window.max() - window.min())
  157. ref_price = float(close.iloc[i - 1])
  158. last_close = float(close.iloc[i])
  159. upper = ref_price + k1 * thrust_range
  160. lower = ref_price - k2 * thrust_range
  161. entry_signal = last_close > upper
  162. exit_signal = last_close < lower
  163. action = '空仓'
  164. if entry_signal and not state:
  165. state = True
  166. action = '买入触发'
  167. elif state and exit_signal:
  168. state = False
  169. action = '卖出触发'
  170. else:
  171. action = '持仓' if state else '空仓'
  172. rows.append({
  173. 'date': df.index[i].strftime('%Y-%m-%d'),
  174. 'close': round(last_close, 2),
  175. 'upper': round(upper, 2),
  176. 'lower': round(lower, 2),
  177. 'entry_signal': '是' if entry_signal else '否',
  178. 'exit_signal': '是' if exit_signal else '否',
  179. 'status': action,
  180. })
  181. return rows[-recent_n:]
  182. def compute_recent_combo_signals(df: pd.DataFrame, recent_n: int = 20) -> list[dict]:
  183. close = df['close']
  184. high = df['high']
  185. low = df['low']
  186. returns = close.pct_change(1)
  187. sma120 = close.rolling(120).mean()
  188. sma150 = close.rolling(150).mean()
  189. roc20 = close.pct_change(20)
  190. roc120 = close.pct_change(120)
  191. vol30 = returns.rolling(30).std()
  192. prev_close = close.shift(1)
  193. tr = pd.concat([
  194. (high - low),
  195. (high - prev_close).abs(),
  196. (low - prev_close).abs(),
  197. ], axis=1).max(axis=1)
  198. atr20 = tr.rolling(20).mean()
  199. highest55_prev = high.rolling(55).max().shift(1)
  200. lowest30_prev = low.rolling(30).min().shift(1)
  201. dt_reg_active = False
  202. hybrid_active = False
  203. hybrid_highest_close = None
  204. rows = []
  205. for i in range(len(df)):
  206. dt_w = 0.0
  207. if i > 120 and not pd.isna(sma120.iloc[i]):
  208. window = close.iloc[i - 20:i]
  209. thrust_range = float(window.max() - window.min())
  210. ref_price = float(close.iloc[i - 1])
  211. upper = ref_price + 0.35 * thrust_range
  212. lower = ref_price - 0.35 * thrust_range
  213. entry_signal = float(close.iloc[i]) > upper and float(close.iloc[i]) > float(sma120.iloc[i])
  214. exit_signal = float(close.iloc[i]) < lower or float(close.iloc[i]) < float(sma120.iloc[i])
  215. if not dt_reg_active and entry_signal:
  216. dt_reg_active = True
  217. elif dt_reg_active and exit_signal:
  218. dt_reg_active = False
  219. dt_w = 1.0 if dt_reg_active else 0.0
  220. mvt_signal = False
  221. mvt_w = 0.0
  222. if not any(pd.isna(x) for x in [roc20.iloc[i], roc120.iloc[i], sma150.iloc[i], vol30.iloc[i]]) and vol30.iloc[i] > 0:
  223. mvt_signal = bool(roc20.iloc[i] > 0 and roc120.iloc[i] > 0 and close.iloc[i] > sma150.iloc[i])
  224. if mvt_signal:
  225. annualized_vol = float(vol30.iloc[i]) * math.sqrt(TRADING_DAYS)
  226. mvt_w = min(1.0, 0.29 / annualized_vol)
  227. hy_w = 0.0
  228. hy_break = False
  229. if i > 55 and not any(pd.isna(x) for x in [highest55_prev.iloc[i], lowest30_prev.iloc[i], vol30.iloc[i], atr20.iloc[i]]) and vol30.iloc[i] > 0:
  230. hy_break = bool(close.iloc[i] > highest55_prev.iloc[i])
  231. channel_exit = bool(close.iloc[i] < lowest30_prev.iloc[i])
  232. if not hybrid_active:
  233. if hy_break:
  234. hybrid_active = True
  235. hybrid_highest_close = float(close.iloc[i])
  236. else:
  237. hybrid_highest_close = max(hybrid_highest_close or float(close.iloc[i]), float(close.iloc[i]))
  238. trailing_stop = hybrid_highest_close - 4.0 * float(atr20.iloc[i])
  239. if channel_exit or float(close.iloc[i]) < trailing_stop:
  240. hybrid_active = False
  241. hybrid_highest_close = None
  242. if hybrid_active:
  243. annualized_vol = float(vol30.iloc[i]) * math.sqrt(TRADING_DAYS)
  244. hy_w = min(1.0, 0.25 / annualized_vol)
  245. target_weight = 0.60 * dt_w + 0.20 * mvt_w + 0.20 * hy_w
  246. rows.append({
  247. 'date': df.index[i].strftime('%Y-%m-%d'),
  248. 'close': round(float(close.iloc[i]), 2),
  249. 'dt_status': '开' if dt_reg_active else '关',
  250. 'mvt_status': '开' if mvt_signal else '关',
  251. 'hy_status': '开' if hybrid_active else '关',
  252. 'dt_weight': round(dt_w * 100, 1),
  253. 'mvt_weight': round(mvt_w * 100, 1),
  254. 'hy_weight': round(hy_w * 100, 1),
  255. 'target_weight': round(target_weight * 100, 1),
  256. })
  257. return rows[-recent_n:]
  258. def build_recent_signal_html(strategy_name: str, recent_rows: list[dict]) -> str:
  259. if not recent_rows:
  260. return ''
  261. if strategy_name == 'DualThrustBasicStrategy':
  262. header = '<tr>' + th('日期') + th('收盘') + th('上轨') + th('下轨') + th('入场触发') + th('出场触发') + th('状态') + '</tr>'
  263. body = []
  264. for r in recent_rows:
  265. body.append('<tr>' + td(r['date']) + td(safe_num(r['close'])) + td(safe_num(r['upper'])) + td(safe_num(r['lower'])) + td(r['entry_signal']) + td(r['exit_signal']) + td(r['status']) + '</tr>')
  266. else:
  267. header = '<tr>' + th('日期') + th('收盘') + th('DT状态') + th('MVT状态') + th('HY状态') + th('DT权重') + th('MVT权重') + th('HY权重') + th('组合目标仓位') + '</tr>'
  268. body = []
  269. for r in recent_rows:
  270. body.append('<tr>' + td(r['date']) + td(safe_num(r['close'])) + td(r['dt_status']) + td(r['mvt_status']) + td(r['hy_status']) + td(f"{r['dt_weight']:.1f}%") + td(f"{r['mvt_weight']:.1f}%") + td(f"{r['hy_weight']:.1f}%") + td(f"{r['target_weight']:.1f}%") + '</tr>')
  271. return f'''<h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">近20交易日指标触发情况</h2>
  272. <table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:22px;">{header}{''.join(body)}</table>'''
  273. def build_html(title: str, strategy_name: str, config_desc: str, metrics: dict, trades: list[dict], subperiods: list[dict], recent_signal_rows: list[dict], df, display_cash: float, scaled_note: str):
  274. total_pnl = sum(t["pnl"] for t in trades)
  275. trade_rows = []
  276. for i, t in enumerate(trades, 1):
  277. pnl_color = "#0b8f3d" if t["pnl_pct"] >= 0 else "#c62828"
  278. pnl_sign = "+" if t["pnl_pct"] > 0 else ""
  279. trade_rows.append(
  280. "<tr>"
  281. + td(i)
  282. + td(t["entry_date"])
  283. + td(safe_num(t["entry_price"]))
  284. + td(t["exit_date"])
  285. + td(safe_num(t["exit_price"]))
  286. + td(safe_num(t["qty"]))
  287. + td(t["days"])
  288. + td(safe_num(t["pnl"]), f"color:{pnl_color};font-weight:700;")
  289. + td(f"{pnl_sign}{t['pnl_pct']:.2f}%", f"color:{pnl_color};font-weight:700;")
  290. + td(safe_num(t["nav"]))
  291. + "</tr>"
  292. )
  293. trade_rows = "\n".join(trade_rows)
  294. sub_rows = []
  295. for sp in subperiods:
  296. sub_rows.append(
  297. "<tr>"
  298. + td(sp["period"])
  299. + td(f"{sp['annual']:.2f}%")
  300. + td(sp["sharpe"])
  301. + td(f"{sp['max_dd']:.2f}%")
  302. + "</tr>"
  303. )
  304. sub_rows = "\n".join(sub_rows)
  305. data_start = df.index.min().date()
  306. data_end = df.index.max().date()
  307. bars = len(df)
  308. recent_signal_html = build_recent_signal_html(strategy_name, recent_signal_rows)
  309. return f"""<!DOCTYPE html>
  310. <html>
  311. <head>
  312. <meta charset="utf-8">
  313. <title>{title}</title>
  314. </head>
  315. <body style="margin:0;padding:24px;background:#f6f8fb;font-family:Arial,'PingFang SC','Microsoft YaHei',sans-serif;color:#243447;">
  316. <div style="max-width:1120px;margin:0 auto;background:#ffffff;border:1px solid #e6ebf2;border-radius:10px;padding:28px;">
  317. <h1 style="margin:0 0 16px 0;font-size:26px;line-height:1.3;color:#1f2d3d;">{title}</h1>
  318. <div style="font-size:14px;line-height:1.8;color:#4a5568;margin-bottom:20px;">
  319. <div><strong>初始资金:</strong>{safe_num(display_cash)}</div>
  320. <div><strong>策略名称:</strong>{strategy_name}</div>
  321. <div><strong>配置参数:</strong>{config_desc}</div>
  322. <div><strong>数据来源:</strong>chinext50.csv({data_start} 至 {data_end},{bars} 根 K 线)</div>
  323. <div><strong>数据处理:</strong>{scaled_note}</div>
  324. </div>
  325. <h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">核心指标</h2>
  326. <table style="width:100%;border-collapse:collapse;font-size:14px;margin-bottom:22px;">
  327. <tr>{th('指标')}{th('数值')}{th('指标')}{th('数值')}</tr>
  328. <tr>{td('总收益')}{td(f"{metrics['total_return_pct']:.2f}%")}{td('年化收益')}{td(f"{metrics['annual_return_pct']:.2f}%")}</tr>
  329. <tr>{td('Sharpe')}{td(metrics['sharpe'])}{td('最大回撤')}{td(f"{metrics['max_drawdown_pct']:.2f}%")}</tr>
  330. <tr>{td('交易次数')}{td(metrics['closed_trades'])}{td('胜率')}{td(f"{metrics['win_rate_pct']:.2f}%")}</tr>
  331. <tr>{td('最终净值')}{td(safe_num(metrics['final_value']))}{td('平均暴露')}{td(f"{metrics['exposure_pct']:.2f}%")}</tr>
  332. </table>
  333. <h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">子区间复核</h2>
  334. <table style="width:100%;border-collapse:collapse;font-size:14px;margin-bottom:22px;">
  335. <tr>{th('区间')}{th('年化收益')}{th('Sharpe')}{th('最大回撤')}</tr>
  336. {sub_rows}
  337. </table>
  338. {recent_signal_html}
  339. <h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">历史交易记录</h2>
  340. <div style="font-size:13px;color:#667085;margin-bottom:10px;">累计盈亏:{safe_num(total_pnl)}</div>
  341. <table style="width:100%;border-collapse:collapse;font-size:13px;">
  342. <tr>{th('#')}{th('买入时间')}{th('买入价')}{th('卖出时间')}{th('卖出价')}{th('数量')}{th('天数')}{th('盈亏额')}{th('盈亏%')}{th('账户净值')}</tr>
  343. {trade_rows}
  344. </table>
  345. <div style="margin-top:18px;background:#fff7e6;border-left:4px solid #f0b429;padding:10px 12px;font-size:13px;color:#7a5c00;">本报告每次运行都会先拉取远程最新数据,刷新本地 chinext50.csv,再重算后发出。</div>
  346. </div>
  347. </body>
  348. </html>"""
  349. class Balanced3_DT60_MVT20_HY20(BasePortfolioStrategy):
  350. params = (("w_dt", 0.60), ("w_mvt", 0.20), ("w_hy", 0.20), ("rebalance_band", 0.05))
  351. def __init__(self):
  352. super().__init__()
  353. close = self.data.close
  354. returns = bt.indicators.PctChange(close, period=1)
  355. self.volatility = bt.indicators.StdDev(returns, period=30)
  356. self.atr = bt.indicators.ATR(self.data, period=20)
  357. self.roc_short = bt.indicators.ROC(close, period=20)
  358. self.roc_long = bt.indicators.ROC(close, period=120)
  359. self.sma120 = bt.indicators.SMA(close, period=120)
  360. self.sma150 = bt.indicators.SMA(close, period=150)
  361. self.highest_high = bt.indicators.Highest(self.data.high, period=55)
  362. self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
  363. self.dt_reg_active = False
  364. self.hybrid_active = False
  365. self.hybrid_highest_close = None
  366. def next(self):
  367. super().next()
  368. if self.order:
  369. return
  370. dt_w = 0.0
  371. if len(self) > 120 and not math.isnan(self.sma120[0]):
  372. closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
  373. thrust_range = max(closes) - min(closes)
  374. reference_price = float(self.data.close[-1])
  375. upper = reference_price + 0.35 * thrust_range
  376. lower = reference_price - 0.35 * thrust_range
  377. entry_signal = self.data.close[0] > upper and self.data.close[0] > self.sma120[0]
  378. exit_signal = self.data.close[0] < lower or self.data.close[0] < self.sma120[0]
  379. if not self.dt_reg_active and entry_signal:
  380. self.dt_reg_active = True
  381. elif self.dt_reg_active and exit_signal:
  382. self.dt_reg_active = False
  383. dt_w = 1.0 if self.dt_reg_active else 0.0
  384. mvt_w = 0.0
  385. if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
  386. signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
  387. if signal:
  388. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  389. mvt_w = min(1.0, 0.29 / annualized_vol)
  390. hy_w = 0.0
  391. if len(self) > 55 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.volatility[0], self.atr[0]]) and self.volatility[0] > 0:
  392. breakout_signal = self.data.close[0] > self.highest_high[-1]
  393. channel_exit = self.data.close[0] < self.lowest_low[-1]
  394. if not self.hybrid_active:
  395. if breakout_signal:
  396. self.hybrid_active = True
  397. self.hybrid_highest_close = float(self.data.close[0])
  398. else:
  399. self.hybrid_highest_close = max(self.hybrid_highest_close or float(self.data.close[0]), float(self.data.close[0]))
  400. trailing_stop = self.hybrid_highest_close - 4.0 * self.atr[0]
  401. if channel_exit or self.data.close[0] < trailing_stop:
  402. self.hybrid_active = False
  403. self.hybrid_highest_close = None
  404. if self.hybrid_active:
  405. annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
  406. hy_w = min(1.0, 0.25 / annualized_vol)
  407. target_weight = self.p.w_dt * dt_w + self.p.w_mvt * mvt_w + self.p.w_hy * hy_w
  408. pv = self.broker.getvalue()
  409. cw = (abs(self.position.size) * self.data.close[0]) / pv if pv > 0 else 0.0
  410. if not self.position or abs(cw - target_weight) >= self.p.rebalance_band:
  411. self._rebalance_to_weight(target_weight)
  412. def write_mail(subject: str, html: str, output_path: Path, to_email: str):
  413. mail = "\n".join(
  414. [
  415. f"Subject: {subject}",
  416. f"From: {to_email}",
  417. f"To: {to_email}",
  418. "MIME-Version: 1.0",
  419. "Content-Type: text/html; charset=utf-8",
  420. "Content-Transfer-Encoding: 8bit",
  421. "",
  422. html,
  423. ]
  424. )
  425. output_path.write_text(mail, encoding="utf-8")
  426. def send_mail(file_path: Path, to_email: str):
  427. content = file_path.read_bytes()
  428. subprocess.run(["/usr/sbin/sendmail", to_email], input=content, check=True)
  429. def get_send_window_status() -> tuple[bool, str]:
  430. now = datetime.now(SEND_TZ)
  431. now_t = now.time()
  432. allowed = SEND_START <= now_t <= SEND_END
  433. msg = (
  434. f"send_window tz=Asia/Shanghai now={now.strftime('%Y-%m-%d %H:%M:%S')} "
  435. f"window={SEND_START.strftime('%H:%M')}-{SEND_END.strftime('%H:%M')} allowed={allowed}"
  436. )
  437. return allowed, msg
  438. def main():
  439. parser = argparse.ArgumentParser()
  440. parser.add_argument("--email", default=DEFAULT_EMAIL)
  441. parser.add_argument("--baseline-cash", type=float, default=DEFAULT_BASELINE_CASH)
  442. parser.add_argument("--display-cash", type=float, default=DEFAULT_DISPLAY_CASH)
  443. parser.add_argument("--skip-refresh", action="store_true")
  444. parser.add_argument("--skip-send", action="store_true")
  445. parser.add_argument("--force-send", action="store_true", help="Ignore send window and send immediately.")
  446. args = parser.parse_args()
  447. if not args.skip_refresh:
  448. try:
  449. refreshed = fetch_chinext50_data(save_path=WORKDIR / "chinext50.csv")
  450. except Exception as e:
  451. print(f"REFRESH_FAILED: {e}", file=sys.stderr)
  452. print("ABORT_SEND: remote refresh failed, report email not sent", file=sys.stderr)
  453. raise SystemExit(2)
  454. print(f"refreshed rows={len(refreshed)} range={refreshed['datetime'].min().date()}~{refreshed['datetime'].max().date()}")
  455. df = exp_mod.load_dataframe()
  456. scale = args.display_cash / args.baseline_cash
  457. scaled_note = (
  458. f"先刷新远程数据后回测;展示口径按 {safe_num(args.baseline_cash)} 基准回测等比例放大到 {safe_num(args.display_cash)}。"
  459. if abs(scale - 1.0) > 1e-9
  460. else f"先刷新远程数据后,按 {safe_num(args.display_cash)} 初始资金直接回测。"
  461. )
  462. m1, t1 = run_with_trades(exp_mod.DualThrustBasicStrategy, df, args.baseline_cash, {"range_period": 20, "k1": 0.3, "k2": 0.3})
  463. m2, t2 = run_with_trades(Balanced3_DT60_MVT20_HY20, df, args.baseline_cash)
  464. if abs(scale - 1.0) > 1e-9:
  465. m1, t1 = scale_metrics(m1, scale), scale_trades(t1, scale)
  466. m2, t2 = scale_metrics(m2, scale), scale_trades(t2, scale)
  467. sub1 = compute_subperiods(
  468. exp_mod.DualThrustBasicStrategy,
  469. df,
  470. args.baseline_cash,
  471. {"range_period": 20, "k1": 0.3, "k2": 0.3},
  472. )
  473. sub2 = compute_subperiods(
  474. Balanced3_DT60_MVT20_HY20,
  475. df,
  476. args.baseline_cash,
  477. None,
  478. )
  479. recent1 = compute_recent_dualthrust_signals(df, range_period=20, k1=0.3, k2=0.3, recent_n=20)
  480. recent2 = compute_recent_combo_signals(df, recent_n=20)
  481. html1 = build_html(
  482. "DualThrustBasicStrategy — 单策略统计信息(自动刷新版)",
  483. "DualThrustBasicStrategy",
  484. "range_period=20, k1=0.3, k2=0.3",
  485. m1,
  486. t1,
  487. sub1,
  488. recent1,
  489. df,
  490. args.display_cash,
  491. scaled_note,
  492. )
  493. html2 = build_html(
  494. "Balanced3_DT60_MVT20_HY20 — 组合策略统计信息(自动刷新版)",
  495. "Balanced3_DT60_MVT20_HY20",
  496. "DualThrustRegime(60%) + MVT_reg150_tv029(20%) + DonchianHybrid_b55_e30_tv025_atr4(20%)",
  497. m2,
  498. t2,
  499. sub2,
  500. recent2,
  501. df,
  502. args.display_cash,
  503. scaled_note,
  504. )
  505. p1 = WORKDIR / "auto_refresh_email_strategy_1.html"
  506. p2 = WORKDIR / "auto_refresh_email_strategy_2.html"
  507. write_mail("【策略统计-自动刷新版】DualThrustBasicStrategy 单策略统计信息", html1, p1, args.email)
  508. write_mail("【策略统计-自动刷新版】Balanced3_DT60_MVT20_HY20 组合策略统计信息", html2, p2, args.email)
  509. print(f"strategy1 total={m1['total_return_pct']}% final={m1['final_value']}")
  510. print(f"strategy2 total={m2['total_return_pct']}% final={m2['final_value']}")
  511. print(f"wrote {p1} and {p2}")
  512. if not args.skip_send:
  513. allowed, window_msg = get_send_window_status()
  514. print(window_msg)
  515. if allowed or args.force_send:
  516. send_mail(p1, args.email)
  517. send_mail(p2, args.email)
  518. print("sent both emails")
  519. else:
  520. print("SKIP_SEND: outside Asia/Shanghai send window; reports generated but not mailed")
  521. if __name__ == "__main__":
  522. main()