report.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. from html import escape
  2. from pathlib import Path
  3. import numpy as np
  4. import pandas as pd
  5. from backtesting import Strategy
  6. from backtesting.lib import FractionalBacktest, crossover
  7. from okx_codex_trader.models import Candle
  8. def render_report_html(
  9. *,
  10. symbol: str,
  11. bar: str,
  12. leverage: int,
  13. stats: dict[str, object],
  14. trades: list[dict[str, object]],
  15. plot_filename: str,
  16. ) -> str:
  17. stat_cards = "".join(
  18. f"""
  19. <div class="card">
  20. <div class="label">{escape(str(label))}</div>
  21. <div class="value">{escape(str(value))}</div>
  22. </div>
  23. """
  24. for label, value in stats.items()
  25. )
  26. trade_rows = "".join(
  27. f"""
  28. <tr>
  29. <td>{escape(str(trade["side"]))}</td>
  30. <td>{escape(str(trade["entry_time"]))}</td>
  31. <td>{escape(str(trade["exit_time"]))}</td>
  32. <td>{escape(str(trade["entry_price"]))}</td>
  33. <td>{escape(str(trade["exit_price"]))}</td>
  34. <td>{escape(str(trade["pnl"]))}</td>
  35. <td>{escape(str(trade["return_pct"]))}</td>
  36. </tr>
  37. """
  38. for trade in trades
  39. )
  40. return f"""<!DOCTYPE html>
  41. <html lang="en">
  42. <head>
  43. <meta charset="utf-8">
  44. <meta name="viewport" content="width=device-width, initial-scale=1">
  45. <title>{escape(symbol)} Backtest Report</title>
  46. <style>
  47. body {{ font-family: Inter, system-ui, sans-serif; margin: 0; background: #f5f1e8; color: #1f1c18; }}
  48. .page {{ max-width: 1280px; margin: 0 auto; padding: 32px 24px 48px; }}
  49. .hero {{ display: flex; justify-content: space-between; gap: 24px; align-items: end; margin-bottom: 24px; }}
  50. .hero h1 {{ margin: 0; font-size: 36px; }}
  51. .meta {{ color: #5f564b; }}
  52. .stats {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 24px; }}
  53. .card {{ background: #fffdf8; border: 1px solid #d8cdbd; border-radius: 16px; padding: 16px; }}
  54. .label {{ font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: #7a6f62; margin-bottom: 8px; }}
  55. .value {{ font-size: 28px; font-weight: 700; }}
  56. .layout {{ display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }}
  57. .panel {{ background: #fffdf8; border: 1px solid #d8cdbd; border-radius: 18px; padding: 18px; }}
  58. table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
  59. th, td {{ text-align: left; padding: 10px 8px; border-bottom: 1px solid #ece3d6; }}
  60. th {{ color: #6b6258; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }}
  61. iframe {{ width: 100%; height: 720px; border: 0; border-radius: 14px; background: white; }}
  62. @media (max-width: 960px) {{
  63. .stats, .layout {{ grid-template-columns: 1fr; }}
  64. iframe {{ height: 520px; }}
  65. }}
  66. </style>
  67. </head>
  68. <body>
  69. <div class="page">
  70. <div class="hero">
  71. <div>
  72. <div class="meta">Journal-first backtest report</div>
  73. <h1>{escape(symbol)}</h1>
  74. </div>
  75. <div class="meta">Bar: {escape(bar)} · Leverage: {escape(str(leverage))}x</div>
  76. </div>
  77. <div class="stats">{stat_cards}</div>
  78. <div class="layout">
  79. <section class="panel">
  80. <h2>Trade Journal</h2>
  81. <table>
  82. <thead>
  83. <tr>
  84. <th>Side</th>
  85. <th>Entry Time</th>
  86. <th>Exit Time</th>
  87. <th>Entry</th>
  88. <th>Exit</th>
  89. <th>PnL</th>
  90. <th>Return %</th>
  91. </tr>
  92. </thead>
  93. <tbody>{trade_rows}</tbody>
  94. </table>
  95. </section>
  96. <section class="panel">
  97. <h2>Chart</h2>
  98. <iframe src="/files/{escape(plot_filename)}" title="Backtest Plot"></iframe>
  99. </section>
  100. </div>
  101. </div>
  102. </body>
  103. </html>"""
  104. def generate_backtest_report(
  105. *,
  106. candles: list[Candle],
  107. leverage: int,
  108. output_file: Path,
  109. symbol: str,
  110. bar: str,
  111. ) -> dict[str, object]:
  112. output_file.parent.mkdir(parents=True, exist_ok=True)
  113. plot_file = output_file.with_name(output_file.stem + ".plot.html")
  114. frame = pd.DataFrame(
  115. {
  116. "Open": [candle.open for candle in candles],
  117. "High": [candle.high for candle in candles],
  118. "Low": [candle.low for candle in candles],
  119. "Close": [candle.close for candle in candles],
  120. "Volume": [candle.volume for candle in candles],
  121. },
  122. index=pd.to_datetime([candle.ts for candle in candles], unit="ms", utc=True),
  123. )
  124. class SmaCross(Strategy):
  125. n1 = 10
  126. n2 = 20
  127. def init(self):
  128. close = self.data.Close.s
  129. self.sma1 = self.I(
  130. lambda values: np.array(pd.Series(values).rolling(self.n1).mean().to_list(), dtype=float),
  131. close,
  132. )
  133. self.sma2 = self.I(
  134. lambda values: np.array(pd.Series(values).rolling(self.n2).mean().to_list(), dtype=float),
  135. close,
  136. )
  137. def next(self):
  138. if crossover(self.sma1, self.sma2):
  139. self.position.close()
  140. self.buy()
  141. elif crossover(self.sma2, self.sma1):
  142. self.position.close()
  143. self.sell()
  144. backtest = FractionalBacktest(
  145. frame,
  146. SmaCross,
  147. cash=10_000,
  148. commission=0.0,
  149. margin=1 / leverage,
  150. trade_on_close=False,
  151. exclusive_orders=True,
  152. hedging=False,
  153. finalize_trades=False,
  154. )
  155. stats = backtest.run()
  156. backtest.plot(filename=str(plot_file), open_browser=False)
  157. summary = {
  158. "Return [%]": round(float(stats["Return [%]"]), 2),
  159. "Win Rate [%]": round(float(stats["Win Rate [%]"]), 2),
  160. "Max. Drawdown [%]": round(float(stats["Max. Drawdown [%]"]), 2),
  161. "# Trades": int(stats["# Trades"]),
  162. }
  163. trades_frame = stats["_trades"]
  164. trades = [
  165. {
  166. "side": "Long" if float(row["Size"]) > 0 else "Short",
  167. "entry_time": row["EntryTime"].strftime("%Y-%m-%d %H:%M"),
  168. "exit_time": row["ExitTime"].strftime("%Y-%m-%d %H:%M"),
  169. "entry_price": round(float(row["EntryPrice"]), 4),
  170. "exit_price": round(float(row["ExitPrice"]), 4),
  171. "pnl": round(float(row["PnL"]), 4),
  172. "return_pct": round(float(row["ReturnPct"]) * 100, 2),
  173. }
  174. for _, row in trades_frame.iterrows()
  175. ]
  176. output_file.write_text(
  177. render_report_html(
  178. symbol=symbol,
  179. bar=bar,
  180. leverage=leverage,
  181. stats=summary,
  182. trades=trades,
  183. plot_filename=plot_file.name,
  184. )
  185. )
  186. return {
  187. "report_file": str(output_file),
  188. "plot_file": str(plot_file),
  189. "trade_count": int(stats["# Trades"]),
  190. "total_return": round(float(stats["Return [%]"]) / 100, 6),
  191. }