From c31e0088db0df916ca546bcb6b94107062f24da3 Mon Sep 17 00:00:00 2001 From: Khizar Date: Fri, 17 Jul 2026 14:54:59 +0000 Subject: [PATCH] feat: add standalone HTML reports --- .github/workflows/ci.yml | 2 +- .gitignore | 3 + README.md | 21 +++ backtestingfx/__init__.py | 2 +- backtestingfx/backtest.py | 35 +++- backtestingfx/plotting.py | 369 ++++++++++++++++++++++++++++++++++++++ examples/html_report.py | 61 +++++++ pyproject.toml | 3 + src/lib.rs | 1 + src/stats.rs | 7 + src/strategy.rs | 3 +- src/types.rs | 8 + tests/test_backtest.py | 41 ++++- 13 files changed, 539 insertions(+), 17 deletions(-) create mode 100644 backtestingfx/plotting.py create mode 100644 examples/html_report.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cfad35..4d79e5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,6 @@ jobs: python-version: "3.12" - run: cargo check - run: cargo test - - run: python -m pip install . + - run: python -m pip install ".[report]" - run: python -m unittest discover -s "$GITHUB_WORKSPACE/tests" working-directory: /tmp diff --git a/.gitignore b/.gitignore index b72680b..0f7f5f5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ CLAUDE.md data/ .env +.venv/ +uv.lock +/backtestingfx-report.html diff --git a/README.md b/README.md index 4ad81b2..f392b7c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,24 @@ Profit Factor: 0.94 Max Drawdown: 3.21% ``` +### Interactive HTML report + +Install the optional report dependency and generate a self-contained HTML file: + +```bash +pip install "backtestingfx[report]" +``` + +```python +bt = Backtest(df, MyCrossStrategy, cash=10000.0, spread=0.0001) +stats = bt.run() +bt.plot("strategy-report.html") +``` + +The report includes candlesticks, trade entries and exits, equity, drawdown, +trade diagnostics, and a complete trade ledger. Plotly is embedded in the file, +so the report works offline without a server. + ## Installation ``` @@ -140,6 +158,9 @@ bt = Backtest(df, MyStrategy, cash=10000.0, spread=0.00015, quote_to_account=1.2 | `worst_trade` | Worst single trade PnL in USD | | `profit_factor` | Gross profit / gross loss | | `max_drawdown_pct` | Maximum drawdown as a percentage | +| `sharpe_ratio` | Unannualized Sharpe ratio | +| `equity_curve` | Account equity from initial cash through final liquidation | +| `trades` | Completed trades with entry, exit, size, direction, and net PnL | ## Data Format diff --git a/backtestingfx/__init__.py b/backtestingfx/__init__.py index 1387cc0..79f0eef 100644 --- a/backtestingfx/__init__.py +++ b/backtestingfx/__init__.py @@ -1,2 +1,2 @@ -from backtestingfx._backtestingfx import Bar, Engine, Stats, Broker # type: ignore +from backtestingfx._backtestingfx import Bar, Broker, Engine, Stats, Trade # type: ignore from backtestingfx.backtest import Strategy, Backtest diff --git a/backtestingfx/backtest.py b/backtestingfx/backtest.py index 7ff87c6..c87c019 100644 --- a/backtestingfx/backtest.py +++ b/backtestingfx/backtest.py @@ -89,15 +89,17 @@ class Backtest: self._spread = spread self._contract_size = contract_size self._quote_to_account = quote_to_account + self._stats = None + self._report_df = None - def _to_bars(self): + def _to_bars(self, df): required = {"open", "high", "low", "close"} - missing = required - set(self._df.columns.str.lower()) + missing = required - set(df.columns.str.lower()) if missing: raise ValueError(f"DataFrame missing required columns: {sorted(missing)}") bars = [] - for idx, row in self._df.iterrows(): + for idx, row in df.iterrows(): if isinstance(idx, pd.Timestamp): ts = int(idx.timestamp()) else: @@ -116,7 +118,10 @@ class Backtest: return bars def run(self): - bars = self._to_bars() + self._stats = None + self._report_df = None + report_df = self._df.copy(deep=True) + bars = self._to_bars(report_df) engine = _rust.Engine( # type: ignore bars, self._cash, @@ -126,4 +131,24 @@ class Backtest: self._quote_to_account, ) strategy = self._strategy_class() - return engine.run(_Adapter(strategy)) + self._stats = engine.run(_Adapter(strategy)) + self._report_df = report_df + return self._stats + + def plot(self, filename="backtest.html", open_browser=True): + if self._stats is None: + raise RuntimeError("Run the backtest before plotting it") + + from backtestingfx.plotting import render_report + + return render_report( + self._report_df, + self._stats, + strategy_name=self._strategy_class.__name__, + filename=filename, + open_browser=open_browser, + commission=self._commission, + spread=self._spread, + contract_size=self._contract_size, + quote_to_account=self._quote_to_account, + ) diff --git a/backtestingfx/plotting.py b/backtestingfx/plotting.py new file mode 100644 index 0000000..597a7f2 --- /dev/null +++ b/backtestingfx/plotting.py @@ -0,0 +1,369 @@ +import html +import math +from pathlib import Path +import webbrowser + +import pandas as pd + + +def render_report( + data, + stats, + *, + strategy_name, + filename, + open_browser, + commission, + spread, + contract_size, + quote_to_account, +): + try: + import plotly.graph_objects as go + import plotly.io as pio + from plotly.subplots import make_subplots + except ModuleNotFoundError as error: + raise ModuleNotFoundError( + 'Plotting requires Plotly. Install it with: pip install "backtestingfx[report]"' + ) from error + + if data.empty: + raise ValueError("Cannot plot a backtest with no bars") + + columns = {str(column).lower(): column for column in data.columns} + if isinstance(data.index, pd.DatetimeIndex): + timestamps = pd.to_datetime(data.index, utc=True) + else: + timestamps = pd.to_datetime(data[columns["timestamp"]], utc=True) + + equity = list(stats.equity_curve) + if len(equity) == len(data) + 1: + equity = equity[1:] + if len(equity) != len(data): + raise ValueError("Equity curve does not match the number of bars") + + equity_series = pd.Series(equity, dtype=float) + peaks = pd.Series([stats.initial_cash, *equity], dtype=float).cummax().iloc[1:] + peaks.index = equity_series.index + drawdown = ((equity_series / peaks) - 1.0).fillna(0.0) * 100.0 + trades = list(stats.trades) + + chart = make_subplots( + rows=3, + cols=1, + shared_xaxes=True, + vertical_spacing=0.045, + row_heights=[0.58, 0.24, 0.18], + ) + chart.add_trace( + go.Candlestick( + x=timestamps, + open=data[columns["open"]], + high=data[columns["high"]], + low=data[columns["low"]], + close=data[columns["close"]], + name="Price", + increasing_line_color="#45d483", + decreasing_line_color="#ff6b57", + ), + row=1, + col=1, + ) + + entry_lines_x = [] + entry_lines_y = [] + for trade in trades: + entry_lines_x.extend( + [ + pd.to_datetime(trade.entry_timestamp, unit="s", utc=True), + pd.to_datetime(trade.exit_timestamp, unit="s", utc=True), + None, + ] + ) + entry_lines_y.extend([trade.entry_price, trade.exit_price, None]) + if trades: + chart.add_trace( + go.Scatter( + x=entry_lines_x, + y=entry_lines_y, + mode="lines", + line={"color": "rgba(190, 190, 190, 0.28)", "width": 1}, + hoverinfo="skip", + showlegend=False, + ), + row=1, + col=1, + ) + + for is_long, label, color, symbol in ( + (True, "Long entry", "#45d483", "triangle-up"), + (False, "Short entry", "#ff6b57", "triangle-down"), + ): + matching = [trade for trade in trades if trade.is_long == is_long] + if matching: + chart.add_trace( + go.Scatter( + x=[pd.to_datetime(t.entry_timestamp, unit="s", utc=True) for t in matching], + y=[t.entry_price for t in matching], + mode="markers", + name=label, + marker={"color": color, "size": 11, "symbol": symbol}, + customdata=[[t.lot_size, t.pnl] for t in matching], + hovertemplate=( + f"{label}
%{{x}}
Price %{{y:.5f}}" + "
Lots %{customdata[0]:.2f}
Net PnL %{customdata[1]:.2f}" + ), + ), + row=1, + col=1, + ) + + if trades: + chart.add_trace( + go.Scatter( + x=[pd.to_datetime(t.exit_timestamp, unit="s", utc=True) for t in trades], + y=[t.exit_price for t in trades], + mode="markers", + name="Exit", + marker={ + "color": ["#45d483" if t.pnl >= 0 else "#ff6b57" for t in trades], + "line": {"color": "#080808", "width": 1}, + "size": 9, + "symbol": "circle", + }, + customdata=[[t.pnl] for t in trades], + hovertemplate=( + "Exit
%{x}
Price %{y:.5f}" + "
Net PnL %{customdata[0]:.2f}" + ), + ), + row=1, + col=1, + ) + + chart.add_trace( + go.Scatter( + x=timestamps, + y=equity, + mode="lines", + name="Equity", + line={"color": "#f1c75b", "width": 2}, + hovertemplate="%{x}
Equity %{y:,.2f}", + ), + row=2, + col=1, + ) + chart.add_hline( + y=stats.initial_cash, + line={"color": "rgba(255,255,255,0.22)", "dash": "dot"}, + row=2, + col=1, + ) + chart.add_trace( + go.Scatter( + x=timestamps, + y=drawdown, + mode="lines", + name="Drawdown", + line={"color": "#ff6b57", "width": 1.5}, + fill="tozeroy", + fillcolor="rgba(255, 107, 87, 0.20)", + hovertemplate="%{x}
Drawdown %{y:.2f}%", + ), + row=3, + col=1, + ) + chart.update_layout( + height=920, + margin={"l": 60, "r": 25, "t": 35, "b": 35}, + paper_bgcolor="#111111", + plot_bgcolor="#111111", + font={"color": "#d0d0d0", "family": "IBM Plex Mono, ui-monospace, monospace"}, + hovermode="x unified", + legend={"orientation": "h", "y": 1.03, "x": 0}, + xaxis_rangeslider_visible=False, + ) + chart.update_xaxes(gridcolor="rgba(255,255,255,0.06)", showspikes=True) + chart.update_yaxes(gridcolor="rgba(255,255,255,0.06)", zeroline=False) + chart.update_yaxes(title_text="Price", row=1, col=1) + chart.update_yaxes(title_text="Equity", row=2, col=1) + chart.update_yaxes(title_text="Drawdown %", row=3, col=1) + + analytics = make_subplots( + rows=1, + cols=2, + subplot_titles=("Trade PnL distribution", "Cumulative realized PnL"), + horizontal_spacing=0.12, + ) + pnls = [trade.pnl for trade in trades] + if pnls: + analytics.add_trace( + go.Histogram(x=pnls, marker_color="#9da3ad", name="Trade PnL"), + row=1, + col=1, + ) + cumulative = pd.Series(pnls).cumsum() + analytics.add_trace( + go.Scatter( + x=list(range(1, len(pnls) + 1)), + y=cumulative, + mode="lines+markers", + line={"color": "#45d483", "width": 2}, + marker={"size": 5}, + name="Cumulative PnL", + ), + row=1, + col=2, + ) + else: + analytics.add_annotation( + text="No completed trades", + x=0.5, + y=0.5, + xref="paper", + yref="paper", + showarrow=False, + ) + analytics.update_layout( + height=390, + margin={"l": 55, "r": 25, "t": 55, "b": 45}, + paper_bgcolor="#111111", + plot_bgcolor="#111111", + font={"color": "#d0d0d0", "family": "IBM Plex Mono, ui-monospace, monospace"}, + showlegend=False, + ) + analytics.update_xaxes(gridcolor="rgba(255,255,255,0.06)") + analytics.update_yaxes(gridcolor="rgba(255,255,255,0.06)", zeroline=False) + + def number(value, suffix="", money=False): + if math.isinf(value): + return "∞" + prefix = "$" if money else "" + return f"{prefix}{value:,.2f}{suffix}" + + metric_values = ( + ("Total return", number(stats.total_return_pct, "%")), + ("Final equity", number(stats.final_cash, money=True)), + ("Max drawdown", number(stats.max_drawdown_pct, "%")), + ("Sharpe", number(stats.sharpe_ratio)), + ("Trades", str(stats.num_trades)), + ("Win rate", number(stats.win_rate_pct, "%")), + ("Profit factor", number(stats.profit_factor)), + ("Average trade", number(stats.avg_pnl, money=True)), + ) + metrics_html = "".join( + f'
{label}{value}
' + for label, value in metric_values + ) + + rows = [] + for trade in trades: + entry_time = pd.to_datetime(trade.entry_timestamp, unit="s", utc=True) + exit_time = pd.to_datetime(trade.exit_timestamp, unit="s", utc=True) + duration = exit_time - entry_time + result_class = "positive" if trade.pnl >= 0 else "negative" + rows.append( + "" + f"{'LONG' if trade.is_long else 'SHORT'}" + f"{html.escape(entry_time.strftime('%Y-%m-%d %H:%M'))}" + f"{html.escape(exit_time.strftime('%Y-%m-%d %H:%M'))}" + f"{trade.entry_price:.5f}" + f"{trade.exit_price:.5f}" + f"{trade.lot_size:.2f}" + f'{trade.pnl:,.2f}' + f"{html.escape(str(duration))}" + "" + ) + if not rows: + rows.append('No completed trades') + + config = {"displaylogo": False, "responsive": True, "scrollZoom": True} + chart_html = pio.to_html( + chart, + full_html=False, + include_plotlyjs=True, + config=config, + ) + analytics_html = pio.to_html( + analytics, + full_html=False, + include_plotlyjs=False, + config=config, + ) + start = timestamps[0].strftime("%Y-%m-%d %H:%M UTC") + end = timestamps[-1].strftime("%Y-%m-%d %H:%M UTC") + safe_strategy_name = html.escape(strategy_name) + + document = f""" + + + + + {safe_strategy_name} | backtestingfx report + + + +
+
+
backtestingfx / strategy report

{safe_strategy_name}

{start} → {end}   /   {len(data):,} bars
+
RUN COMPLETE
+
+
{metrics_html}
+
+

Market replay

+ {chart_html} +
+
Commission / lot / side{commission:,.4f}
+
Spread offset{spread:,.5f}
+
Contract size{contract_size:,.0f}
+
Quote conversion{quote_to_account:,.5f}
+
+
+

Trade diagnostics

{analytics_html}
+
+

Trade ledger

+
{''.join(rows)}
SideEntry timeExit timeEntryExitLotsNet PnLDuration
+
+
Generated by backtestingfxResearch output, not financial advice
+
+ +""" + + output = Path(filename).expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(document, encoding="utf-8") + if open_browser: + webbrowser.open(output.as_uri()) + return str(output) diff --git a/examples/html_report.py b/examples/html_report.py new file mode 100644 index 0000000..fd3f492 --- /dev/null +++ b/examples/html_report.py @@ -0,0 +1,61 @@ +import math + +import pandas as pd + +from backtestingfx import Backtest, Strategy + + +class MovingAverageCycle(Strategy): + fast = 8 + slow = 24 + + def next(self): + if self.index + 1 < self.slow: + return + + closes = [bar.close for bar in self.data[-self.slow :]] + fast_average = sum(closes[-self.fast :]) / self.fast + slow_average = sum(closes) / self.slow + should_be_long = fast_average > slow_average + + if self.positions and self.positions[0].is_long != should_be_long: + self.close_all() + + if not self.positions: + if should_be_long: + self.buy(0.1) + else: + self.sell(0.1) + + +def sample_data(): + timestamps = pd.date_range("2025-01-01", periods=360, freq="h", tz="UTC") + closes = [ + 1.1000 + 0.0040 * math.sin(i / 13) + 0.0010 * math.sin(i / 3) + for i in range(len(timestamps)) + ] + opens = [closes[0], *closes[:-1]] + + return pd.DataFrame( + { + "open": opens, + "high": [max(open_, close) + 0.0004 for open_, close in zip(opens, closes)], + "low": [min(open_, close) - 0.0004 for open_, close in zip(opens, closes)], + "close": closes, + "volume": [800 + int(300 * abs(math.sin(i / 9))) for i in range(len(timestamps))], + }, + index=timestamps, + ) + + +data = sample_data() +backtest = Backtest( + data, + MovingAverageCycle, + cash=10_000, + commission=3.5, + spread=0.00002, +) + +print(backtest.run()) +print(f"\nReport written to: {backtest.plot('backtestingfx-report.html')}") diff --git a/pyproject.toml b/pyproject.toml index c37e4c0..2619179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,9 @@ dependencies = ["pandas"] license = { text = "MIT" } readme = "README.md" +[project.optional-dependencies] +report = ["plotly>=5"] + [tool.maturin] module-name = "backtestingfx._backtestingfx" features = ["pyo3/extension-module"] diff --git a/src/lib.rs b/src/lib.rs index 81f092d..6473d37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,5 +15,6 @@ fn backtestingfx(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/src/stats.rs b/src/stats.rs index 4383ad3..96edeec 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -1,4 +1,5 @@ use crate::broker::Broker; +use crate::types::Trade; use pyo3::prelude::*; #[pyclass] @@ -27,6 +28,10 @@ pub struct Stats { pub max_drawdown_pct: f64, #[pyo3(get)] pub sharpe_ratio: f64, + #[pyo3(get)] + pub equity_curve: Vec, + #[pyo3(get)] + pub trades: Vec, } fn sharpe_ratio(equity_curve: &[f64]) -> f64 { @@ -134,6 +139,8 @@ impl Stats { profit_factor, max_drawdown_pct, sharpe_ratio, + equity_curve: equity_curve.to_vec(), + trades: broker.trade_history.clone(), } } } diff --git a/src/strategy.rs b/src/strategy.rs index 4fdb677..6d99c43 100644 --- a/src/strategy.rs +++ b/src/strategy.rs @@ -1,8 +1,7 @@ -use crate::types::Bar; use crate::broker::Broker; +use crate::types::Bar; pub trait Strategy { fn init(&mut self, _data: &[Bar]) {} fn next(&mut self, bar: &Bar, broker: &mut Broker); } - diff --git a/src/types.rs b/src/types.rs index 5597377..e34fbf4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -66,14 +66,22 @@ impl Position { } } +#[pyclass(from_py_object)] #[derive(Debug, Clone)] pub struct Trade { // the actual trade + #[pyo3(get)] pub entry_price: f64, + #[pyo3(get)] pub exit_price: f64, + #[pyo3(get)] pub lot_size: f64, + #[pyo3(get)] pub is_long: bool, + #[pyo3(get)] pub pnl: f64, + #[pyo3(get)] pub entry_timestamp: i64, + #[pyo3(get)] pub exit_timestamp: i64, } diff --git a/tests/test_backtest.py b/tests/test_backtest.py index 4fb3813..7ccfc0f 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -1,3 +1,5 @@ +from pathlib import Path +import tempfile import unittest import pandas as pd @@ -7,33 +9,56 @@ from backtestingfx import Backtest, Strategy class BuyAndHold(Strategy): def next(self): - self.buy(1.0) + if not self.positions: + self.buy(1.0) class BacktestTest(unittest.TestCase): def test_run_returns_stats_from_python_strategy(self): data = pd.DataFrame( { - "open": [1.1], - "high": [1.1], - "low": [1.1], - "close": [1.1], + "open": [1.1, 1.1], + "high": [1.1, 1.1], + "low": [1.1, 1.1], + "close": [1.1, 1.1], }, - index=pd.to_datetime(["2026-01-01"], utc=True), + index=pd.to_datetime(["2026-01-01 00:00", "2026-01-01 01:00"], utc=True), ) - stats = Backtest( + backtest = Backtest( data, BuyAndHold, cash=10_000.0, commission=7.0, spread=0.0, - ).run() + ) + stats = backtest.run() self.assertEqual(stats.initial_cash, 10_000.0) self.assertEqual(stats.final_cash, 9_986.0) self.assertEqual(stats.num_trades, 1) self.assertEqual(stats.avg_pnl, -14.0) + self.assertEqual(stats.equity_curve, [10_000.0, 9_993.0, 9_986.0]) + self.assertEqual(len(stats.trades), 1) + self.assertEqual(stats.trades[0].pnl, -14.0) + self.assertEqual( + stats.trades[0].exit_timestamp - stats.trades[0].entry_timestamp, + 3_600, + ) + + data.drop(index=data.index[-1], inplace=True) + with tempfile.TemporaryDirectory() as directory: + report = Path( + backtest.plot(Path(directory) / "report.html", open_browser=False) + ) + contents = report.read_text(encoding="utf-8") + + self.assertTrue(report.is_file()) + self.assertIn("BuyAndHold | backtestingfx report", contents) + self.assertIn("Market replay", contents) + self.assertIn("Plotly.newPlot", contents) + self.assertIn("2026-01-01 00:00", contents) + self.assertIn("2026-01-01 01:00", contents) if __name__ == "__main__":