mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-07-27 12:07:44 +00:00
feat: add standalone HTML reports
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -6,3 +6,6 @@ __pycache__/
|
||||
CLAUDE.md
|
||||
data/
|
||||
.env
|
||||
.venv/
|
||||
uv.lock
|
||||
/backtestingfx-report.html
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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" }
|
||||
readme = "README.md"
|
||||
|
||||
[project.optional-dependencies]
|
||||
report = ["plotly>=5"]
|
||||
|
||||
[tool.maturin]
|
||||
module-name = "backtestingfx._backtestingfx"
|
||||
features = ["pyo3/extension-module"]
|
||||
|
||||
@@ -15,5 +15,6 @@ fn backtestingfx(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<broker::Broker>()?;
|
||||
m.add_class::<engine::Engine>()?;
|
||||
m.add_class::<types::Position>()?;
|
||||
m.add_class::<types::Trade>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
#[pyo3(get)]
|
||||
pub trades: Vec<Trade>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+33
-8
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user