| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- from html import escape
- from pathlib import Path
- import numpy as np
- import pandas as pd
- from backtesting import Strategy
- from backtesting.lib import FractionalBacktest, crossover
- from okx_codex_trader.models import Candle
- def render_report_html(
- *,
- symbol: str,
- bar: str,
- leverage: int,
- stats: dict[str, object],
- trades: list[dict[str, object]],
- plot_filename: str,
- ) -> str:
- stat_cards = "".join(
- f"""
- <div class="card">
- <div class="label">{escape(str(label))}</div>
- <div class="value">{escape(str(value))}</div>
- </div>
- """
- for label, value in stats.items()
- )
- trade_rows = "".join(
- f"""
- <tr>
- <td>{escape(str(trade["side"]))}</td>
- <td>{escape(str(trade["entry_time"]))}</td>
- <td>{escape(str(trade["exit_time"]))}</td>
- <td>{escape(str(trade["entry_price"]))}</td>
- <td>{escape(str(trade["exit_price"]))}</td>
- <td>{escape(str(trade["pnl"]))}</td>
- <td>{escape(str(trade["return_pct"]))}</td>
- </tr>
- """
- for trade in trades
- )
- return f"""<!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>{escape(symbol)} Backtest Report</title>
- <style>
- body {{ font-family: Inter, system-ui, sans-serif; margin: 0; background: #f5f1e8; color: #1f1c18; }}
- .page {{ max-width: 1280px; margin: 0 auto; padding: 32px 24px 48px; }}
- .hero {{ display: flex; justify-content: space-between; gap: 24px; align-items: end; margin-bottom: 24px; }}
- .hero h1 {{ margin: 0; font-size: 36px; }}
- .meta {{ color: #5f564b; }}
- .stats {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 24px; }}
- .card {{ background: #fffdf8; border: 1px solid #d8cdbd; border-radius: 16px; padding: 16px; }}
- .label {{ font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: #7a6f62; margin-bottom: 8px; }}
- .value {{ font-size: 28px; font-weight: 700; }}
- .layout {{ display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }}
- .panel {{ background: #fffdf8; border: 1px solid #d8cdbd; border-radius: 18px; padding: 18px; }}
- table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
- th, td {{ text-align: left; padding: 10px 8px; border-bottom: 1px solid #ece3d6; }}
- th {{ color: #6b6258; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }}
- iframe {{ width: 100%; height: 720px; border: 0; border-radius: 14px; background: white; }}
- @media (max-width: 960px) {{
- .stats, .layout {{ grid-template-columns: 1fr; }}
- iframe {{ height: 520px; }}
- }}
- </style>
- </head>
- <body>
- <div class="page">
- <div class="hero">
- <div>
- <div class="meta">Journal-first backtest report</div>
- <h1>{escape(symbol)}</h1>
- </div>
- <div class="meta">Bar: {escape(bar)} · Leverage: {escape(str(leverage))}x</div>
- </div>
- <div class="stats">{stat_cards}</div>
- <div class="layout">
- <section class="panel">
- <h2>Trade Journal</h2>
- <table>
- <thead>
- <tr>
- <th>Side</th>
- <th>Entry Time</th>
- <th>Exit Time</th>
- <th>Entry</th>
- <th>Exit</th>
- <th>PnL</th>
- <th>Return %</th>
- </tr>
- </thead>
- <tbody>{trade_rows}</tbody>
- </table>
- </section>
- <section class="panel">
- <h2>Chart</h2>
- <iframe src="/files/{escape(plot_filename)}" title="Backtest Plot"></iframe>
- </section>
- </div>
- </div>
- </body>
- </html>"""
- def generate_backtest_report(
- *,
- candles: list[Candle],
- leverage: int,
- output_file: Path,
- symbol: str,
- bar: str,
- ) -> dict[str, object]:
- output_file.parent.mkdir(parents=True, exist_ok=True)
- plot_file = output_file.with_name(output_file.stem + ".plot.html")
- frame = pd.DataFrame(
- {
- "Open": [candle.open for candle in candles],
- "High": [candle.high for candle in candles],
- "Low": [candle.low for candle in candles],
- "Close": [candle.close for candle in candles],
- "Volume": [candle.volume for candle in candles],
- },
- index=pd.to_datetime([candle.ts for candle in candles], unit="ms", utc=True),
- )
- class SmaCross(Strategy):
- n1 = 10
- n2 = 20
- def init(self):
- close = self.data.Close.s
- self.sma1 = self.I(
- lambda values: np.array(pd.Series(values).rolling(self.n1).mean().to_list(), dtype=float),
- close,
- )
- self.sma2 = self.I(
- lambda values: np.array(pd.Series(values).rolling(self.n2).mean().to_list(), dtype=float),
- close,
- )
- def next(self):
- if crossover(self.sma1, self.sma2):
- self.position.close()
- self.buy()
- elif crossover(self.sma2, self.sma1):
- self.position.close()
- self.sell()
- backtest = FractionalBacktest(
- frame,
- SmaCross,
- cash=10_000,
- commission=0.0,
- margin=1 / leverage,
- trade_on_close=False,
- exclusive_orders=True,
- hedging=False,
- finalize_trades=False,
- )
- stats = backtest.run()
- backtest.plot(filename=str(plot_file), open_browser=False)
- summary = {
- "Return [%]": round(float(stats["Return [%]"]), 2),
- "Win Rate [%]": round(float(stats["Win Rate [%]"]), 2),
- "Max. Drawdown [%]": round(float(stats["Max. Drawdown [%]"]), 2),
- "# Trades": int(stats["# Trades"]),
- }
- trades_frame = stats["_trades"]
- trades = [
- {
- "side": "Long" if float(row["Size"]) > 0 else "Short",
- "entry_time": row["EntryTime"].strftime("%Y-%m-%d %H:%M"),
- "exit_time": row["ExitTime"].strftime("%Y-%m-%d %H:%M"),
- "entry_price": round(float(row["EntryPrice"]), 4),
- "exit_price": round(float(row["ExitPrice"]), 4),
- "pnl": round(float(row["PnL"]), 4),
- "return_pct": round(float(row["ReturnPct"]) * 100, 2),
- }
- for _, row in trades_frame.iterrows()
- ]
- output_file.write_text(
- render_report_html(
- symbol=symbol,
- bar=bar,
- leverage=leverage,
- stats=summary,
- trades=trades,
- plot_filename=plot_file.name,
- )
- )
- return {
- "report_file": str(output_file),
- "plot_file": str(plot_file),
- "trade_count": int(stats["# Trades"]),
- "total_return": round(float(stats["Return [%]"]) / 100, 6),
- }
|