mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-07-27 20:17:44 +00:00
feat: add standalone HTML reports
This commit is contained in:
@@ -16,6 +16,6 @@ jobs:
|
|||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- run: cargo check
|
- run: cargo check
|
||||||
- run: cargo test
|
- run: cargo test
|
||||||
- run: python -m pip install .
|
- run: python -m pip install ".[report]"
|
||||||
- run: python -m unittest discover -s "$GITHUB_WORKSPACE/tests"
|
- run: python -m unittest discover -s "$GITHUB_WORKSPACE/tests"
|
||||||
working-directory: /tmp
|
working-directory: /tmp
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ __pycache__/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
data/
|
data/
|
||||||
.env
|
.env
|
||||||
|
.venv/
|
||||||
|
uv.lock
|
||||||
|
/backtestingfx-report.html
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ Profit Factor: 0.94
|
|||||||
Max Drawdown: 3.21%
|
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
|
## 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 |
|
| `worst_trade` | Worst single trade PnL in USD |
|
||||||
| `profit_factor` | Gross profit / gross loss |
|
| `profit_factor` | Gross profit / gross loss |
|
||||||
| `max_drawdown_pct` | Maximum drawdown as a percentage |
|
| `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
|
## Data Format
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from backtestingfx.backtest import Strategy, Backtest
|
||||||
|
|||||||
@@ -89,15 +89,17 @@ class Backtest:
|
|||||||
self._spread = spread
|
self._spread = spread
|
||||||
self._contract_size = contract_size
|
self._contract_size = contract_size
|
||||||
self._quote_to_account = quote_to_account
|
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"}
|
required = {"open", "high", "low", "close"}
|
||||||
missing = required - set(self._df.columns.str.lower())
|
missing = required - set(df.columns.str.lower())
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"DataFrame missing required columns: {sorted(missing)}")
|
raise ValueError(f"DataFrame missing required columns: {sorted(missing)}")
|
||||||
|
|
||||||
bars = []
|
bars = []
|
||||||
for idx, row in self._df.iterrows():
|
for idx, row in df.iterrows():
|
||||||
if isinstance(idx, pd.Timestamp):
|
if isinstance(idx, pd.Timestamp):
|
||||||
ts = int(idx.timestamp())
|
ts = int(idx.timestamp())
|
||||||
else:
|
else:
|
||||||
@@ -116,7 +118,10 @@ class Backtest:
|
|||||||
return bars
|
return bars
|
||||||
|
|
||||||
def run(self):
|
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
|
engine = _rust.Engine( # type: ignore
|
||||||
bars,
|
bars,
|
||||||
self._cash,
|
self._cash,
|
||||||
@@ -126,4 +131,24 @@ class Backtest:
|
|||||||
self._quote_to_account,
|
self._quote_to_account,
|
||||||
)
|
)
|
||||||
strategy = self._strategy_class()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}<br>%{{x}}<br>Price %{{y:.5f}}"
|
||||||
|
"<br>Lots %{customdata[0]:.2f}<br>Net PnL %{customdata[1]:.2f}<extra></extra>"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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<br>%{x}<br>Price %{y:.5f}"
|
||||||
|
"<br>Net PnL %{customdata[0]:.2f}<extra></extra>"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
row=1,
|
||||||
|
col=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
chart.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=timestamps,
|
||||||
|
y=equity,
|
||||||
|
mode="lines",
|
||||||
|
name="Equity",
|
||||||
|
line={"color": "#f1c75b", "width": 2},
|
||||||
|
hovertemplate="%{x}<br>Equity %{y:,.2f}<extra></extra>",
|
||||||
|
),
|
||||||
|
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}<br>Drawdown %{y:.2f}%<extra></extra>",
|
||||||
|
),
|
||||||
|
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'<div class="metric"><span>{label}</span><strong>{value}</strong></div>'
|
||||||
|
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(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{'LONG' if trade.is_long else 'SHORT'}</td>"
|
||||||
|
f"<td>{html.escape(entry_time.strftime('%Y-%m-%d %H:%M'))}</td>"
|
||||||
|
f"<td>{html.escape(exit_time.strftime('%Y-%m-%d %H:%M'))}</td>"
|
||||||
|
f"<td>{trade.entry_price:.5f}</td>"
|
||||||
|
f"<td>{trade.exit_price:.5f}</td>"
|
||||||
|
f"<td>{trade.lot_size:.2f}</td>"
|
||||||
|
f'<td class="{result_class}">{trade.pnl:,.2f}</td>'
|
||||||
|
f"<td>{html.escape(str(duration))}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
rows.append('<tr><td colspan="8" class="empty">No completed trades</td></tr>')
|
||||||
|
|
||||||
|
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"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{safe_strategy_name} | backtestingfx report</title>
|
||||||
|
<style>
|
||||||
|
:root {{ color-scheme: dark; --bg: #080808; --panel: #111111; --line: #2b2b2b; --ink: #f1f1f1; --muted: #929292; --accent: #d8d8d8; --green: #45d483; --red: #ff6b57; --gold: #f1c75b; }}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{ margin: 0; background: var(--bg); color: var(--ink); font-family: Inter, ui-sans-serif, system-ui, sans-serif; }}
|
||||||
|
body::before {{ content: ""; position: fixed; inset: 0; pointer-events: none; background-image: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px); background-size: 42px 42px; mask-image: linear-gradient(to bottom, black, transparent 65%); }}
|
||||||
|
main {{ width: min(1500px, calc(100% - 40px)); margin: 0 auto; padding: 38px 0 70px; position: relative; }}
|
||||||
|
header {{ display: flex; justify-content: space-between; gap: 30px; align-items: end; padding: 8px 0 28px; border-bottom: 1px solid var(--line); }}
|
||||||
|
.eyebrow {{ color: var(--accent); font: 700 12px/1.4 ui-monospace, monospace; letter-spacing: .18em; text-transform: uppercase; }}
|
||||||
|
h1 {{ margin: 8px 0 14px; font-size: clamp(34px, 4.5vw, 64px); line-height: 1.08; letter-spacing: -.045em; overflow-wrap: anywhere; }}
|
||||||
|
.period {{ color: var(--muted); font: 13px/1.6 ui-monospace, monospace; }}
|
||||||
|
.status {{ border: 1px solid #444; color: var(--accent); background: rgba(255,255,255,.04); border-radius: 999px; padding: 9px 14px; font: 700 11px ui-monospace, monospace; letter-spacing: .12em; white-space: nowrap; }}
|
||||||
|
.metrics {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 28px 0; background: var(--line); border: 1px solid var(--line); }}
|
||||||
|
.metric {{ background: var(--panel); padding: 18px 20px; min-height: 98px; display: flex; flex-direction: column; justify-content: space-between; }}
|
||||||
|
.metric span, .section-label {{ color: var(--muted); font: 700 10px ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }}
|
||||||
|
.metric strong {{ font: 600 clamp(21px, 2vw, 31px) ui-monospace, monospace; letter-spacing: -.04em; }}
|
||||||
|
.panel {{ background: var(--panel); border: 1px solid var(--line); margin-top: 18px; overflow: hidden; }}
|
||||||
|
.panel-head {{ display: flex; justify-content: space-between; align-items: center; padding: 18px 22px; border-bottom: 1px solid var(--line); }}
|
||||||
|
.panel-head h2 {{ margin: 0; font-size: 17px; letter-spacing: -.02em; }}
|
||||||
|
.assumptions {{ display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); }}
|
||||||
|
.assumption {{ padding: 15px 20px; border-right: 1px solid var(--line); }}
|
||||||
|
.assumption:last-child {{ border-right: 0; }}
|
||||||
|
.assumption span {{ display: block; color: var(--muted); font: 10px ui-monospace, monospace; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 5px; }}
|
||||||
|
.assumption strong {{ font: 14px ui-monospace, monospace; }}
|
||||||
|
.table-wrap {{ overflow-x: auto; }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; font: 12px ui-monospace, monospace; }}
|
||||||
|
th {{ color: var(--muted); text-align: left; font-size: 10px; letter-spacing: .09em; text-transform: uppercase; }}
|
||||||
|
th, td {{ padding: 13px 16px; border-bottom: 1px solid var(--line); white-space: nowrap; }}
|
||||||
|
tbody tr:hover {{ background: rgba(255,255,255,.025); }}
|
||||||
|
.positive {{ color: var(--green); }} .negative {{ color: var(--red); }} .empty {{ color: var(--muted); text-align: center; padding: 30px; }}
|
||||||
|
footer {{ color: var(--muted); display: flex; justify-content: space-between; margin-top: 28px; font: 10px ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }}
|
||||||
|
@media (max-width: 900px) {{ .metrics {{ grid-template-columns: repeat(2, 1fr); }} .assumptions {{ grid-template-columns: repeat(2, 1fr); }} header {{ align-items: start; flex-direction: column; }} }}
|
||||||
|
@media (max-width: 560px) {{ main {{ width: min(100% - 20px, 1500px); padding-top: 20px; }} .metrics {{ grid-template-columns: 1fr; }} .assumptions {{ grid-template-columns: 1fr; }} .metric {{ min-height: 82px; }} }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div><div class="eyebrow">backtestingfx / strategy report</div><h1>{safe_strategy_name}</h1><div class="period">{start} → {end} / {len(data):,} bars</div></div>
|
||||||
|
<div class="status">RUN COMPLETE</div>
|
||||||
|
</header>
|
||||||
|
<section class="metrics">{metrics_html}</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h2>Market replay</h2><span class="section-label">Price / Equity / Drawdown</span></div>
|
||||||
|
{chart_html}
|
||||||
|
<div class="assumptions">
|
||||||
|
<div class="assumption"><span>Commission / lot / side</span><strong>{commission:,.4f}</strong></div>
|
||||||
|
<div class="assumption"><span>Spread offset</span><strong>{spread:,.5f}</strong></div>
|
||||||
|
<div class="assumption"><span>Contract size</span><strong>{contract_size:,.0f}</strong></div>
|
||||||
|
<div class="assumption"><span>Quote conversion</span><strong>{quote_to_account:,.5f}</strong></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><div class="panel-head"><h2>Trade diagnostics</h2><span class="section-label">Distribution / Sequence</span></div>{analytics_html}</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head"><h2>Trade ledger</h2><span class="section-label">{len(trades)} completed</span></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>Side</th><th>Entry time</th><th>Exit time</th><th>Entry</th><th>Exit</th><th>Lots</th><th>Net PnL</th><th>Duration</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<footer><span>Generated by backtestingfx</span><span>Research output, not financial advice</span></footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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')}")
|
||||||
@@ -11,6 +11,9 @@ dependencies = ["pandas"]
|
|||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
report = ["plotly>=5"]
|
||||||
|
|
||||||
[tool.maturin]
|
[tool.maturin]
|
||||||
module-name = "backtestingfx._backtestingfx"
|
module-name = "backtestingfx._backtestingfx"
|
||||||
features = ["pyo3/extension-module"]
|
features = ["pyo3/extension-module"]
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ fn backtestingfx(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_class::<broker::Broker>()?;
|
m.add_class::<broker::Broker>()?;
|
||||||
m.add_class::<engine::Engine>()?;
|
m.add_class::<engine::Engine>()?;
|
||||||
m.add_class::<types::Position>()?;
|
m.add_class::<types::Position>()?;
|
||||||
|
m.add_class::<types::Trade>()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::broker::Broker;
|
use crate::broker::Broker;
|
||||||
|
use crate::types::Trade;
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
#[pyclass]
|
#[pyclass]
|
||||||
@@ -27,6 +28,10 @@ pub struct Stats {
|
|||||||
pub max_drawdown_pct: f64,
|
pub max_drawdown_pct: f64,
|
||||||
#[pyo3(get)]
|
#[pyo3(get)]
|
||||||
pub sharpe_ratio: f64,
|
pub sharpe_ratio: f64,
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub equity_curve: Vec<f64>,
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub trades: Vec<Trade>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sharpe_ratio(equity_curve: &[f64]) -> f64 {
|
fn sharpe_ratio(equity_curve: &[f64]) -> f64 {
|
||||||
@@ -134,6 +139,8 @@ impl Stats {
|
|||||||
profit_factor,
|
profit_factor,
|
||||||
max_drawdown_pct,
|
max_drawdown_pct,
|
||||||
sharpe_ratio,
|
sharpe_ratio,
|
||||||
|
equity_curve: equity_curve.to_vec(),
|
||||||
|
trades: broker.trade_history.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,7 @@
|
|||||||
use crate::types::Bar;
|
|
||||||
use crate::broker::Broker;
|
use crate::broker::Broker;
|
||||||
|
use crate::types::Bar;
|
||||||
|
|
||||||
pub trait Strategy {
|
pub trait Strategy {
|
||||||
fn init(&mut self, _data: &[Bar]) {}
|
fn init(&mut self, _data: &[Bar]) {}
|
||||||
fn next(&mut self, bar: &Bar, broker: &mut Broker);
|
fn next(&mut self, bar: &Bar, broker: &mut Broker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,14 +66,22 @@ impl Position {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[pyclass(from_py_object)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Trade {
|
pub struct Trade {
|
||||||
// the actual trade
|
// the actual trade
|
||||||
|
#[pyo3(get)]
|
||||||
pub entry_price: f64,
|
pub entry_price: f64,
|
||||||
|
#[pyo3(get)]
|
||||||
pub exit_price: f64,
|
pub exit_price: f64,
|
||||||
|
#[pyo3(get)]
|
||||||
pub lot_size: f64,
|
pub lot_size: f64,
|
||||||
|
#[pyo3(get)]
|
||||||
pub is_long: bool,
|
pub is_long: bool,
|
||||||
|
#[pyo3(get)]
|
||||||
pub pnl: f64,
|
pub pnl: f64,
|
||||||
|
#[pyo3(get)]
|
||||||
pub entry_timestamp: i64,
|
pub entry_timestamp: i64,
|
||||||
|
#[pyo3(get)]
|
||||||
pub exit_timestamp: i64,
|
pub exit_timestamp: i64,
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-8
@@ -1,3 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -7,33 +9,56 @@ from backtestingfx import Backtest, Strategy
|
|||||||
|
|
||||||
class BuyAndHold(Strategy):
|
class BuyAndHold(Strategy):
|
||||||
def next(self):
|
def next(self):
|
||||||
self.buy(1.0)
|
if not self.positions:
|
||||||
|
self.buy(1.0)
|
||||||
|
|
||||||
|
|
||||||
class BacktestTest(unittest.TestCase):
|
class BacktestTest(unittest.TestCase):
|
||||||
def test_run_returns_stats_from_python_strategy(self):
|
def test_run_returns_stats_from_python_strategy(self):
|
||||||
data = pd.DataFrame(
|
data = pd.DataFrame(
|
||||||
{
|
{
|
||||||
"open": [1.1],
|
"open": [1.1, 1.1],
|
||||||
"high": [1.1],
|
"high": [1.1, 1.1],
|
||||||
"low": [1.1],
|
"low": [1.1, 1.1],
|
||||||
"close": [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,
|
data,
|
||||||
BuyAndHold,
|
BuyAndHold,
|
||||||
cash=10_000.0,
|
cash=10_000.0,
|
||||||
commission=7.0,
|
commission=7.0,
|
||||||
spread=0.0,
|
spread=0.0,
|
||||||
).run()
|
)
|
||||||
|
stats = backtest.run()
|
||||||
|
|
||||||
self.assertEqual(stats.initial_cash, 10_000.0)
|
self.assertEqual(stats.initial_cash, 10_000.0)
|
||||||
self.assertEqual(stats.final_cash, 9_986.0)
|
self.assertEqual(stats.final_cash, 9_986.0)
|
||||||
self.assertEqual(stats.num_trades, 1)
|
self.assertEqual(stats.num_trades, 1)
|
||||||
self.assertEqual(stats.avg_pnl, -14.0)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user