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"""
| {escape(str(trade["side"]))} |
{escape(str(trade["entry_time"]))} |
{escape(str(trade["exit_time"]))} |
{escape(str(trade["entry_price"]))} |
{escape(str(trade["exit_price"]))} |
{escape(str(trade["pnl"]))} |
{escape(str(trade["return_pct"]))} |
"""
for trade in trades
)
return f"""
Journal-first backtest report
{escape(symbol)}
Bar: {escape(bar)} ยท Leverage: {escape(str(leverage))}x
{stat_cards}
Trade Journal
| Side |
Entry Time |
Exit Time |
Entry |
Exit |
PnL |
Return % |
{trade_rows}
"""
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),
}