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
+
+
+
+
+
+
+
+ Market replay
Price / Equity / Drawdown
+ {chart_html}
+
+
Commission / lot / side{commission:,.4f}
+
Spread offset{spread:,.5f}
+
Contract size{contract_size:,.0f}
+
Quote conversion{quote_to_account:,.5f}
+
+
+ Trade diagnostics
Distribution / Sequence{analytics_html}
+
+ Trade ledger
{len(trades)} completed
+ | Side | Entry time | Exit time | Entry | Exit | Lots | Net PnL | Duration |
{''.join(rows)}
+
+
+
+
+"""
+
+ 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__":