feat: add standalone HTML reports

This commit is contained in:
Khizar
2026-07-17 14:54:59 +00:00
parent 4fcadca02e
commit c31e0088db
13 changed files with 539 additions and 17 deletions
+1 -1
View File
@@ -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
+3
View File
@@ -6,3 +6,6 @@ __pycache__/
CLAUDE.md CLAUDE.md
data/ data/
.env .env
.venv/
uv.lock
/backtestingfx-report.html
+21
View File
@@ -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 -1
View File
@@ -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
+30 -5
View File
@@ -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,
)
+369
View File
@@ -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 "&infin;"
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} &rarr; {end} &nbsp; / &nbsp; {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)
+61
View File
@@ -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')}")
+3
View File
@@ -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"]
+1
View File
@@ -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(())
} }
+7
View File
@@ -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
View File
@@ -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);
} }
+8
View File
@@ -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
View File
@@ -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__":