backtestingfx 0.2.0__cp313-cp313-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backtestingfx/__init__.py +2 -0
- backtestingfx/_backtestingfx.cp313-win_amd64.pyd +0 -0
- backtestingfx/backtest.py +240 -0
- backtestingfx/plotting.py +369 -0
- backtestingfx-0.2.0.dist-info/METADATA +265 -0
- backtestingfx-0.2.0.dist-info/RECORD +9 -0
- backtestingfx-0.2.0.dist-info/WHEEL +4 -0
- backtestingfx-0.2.0.dist-info/licenses/LICENSE +21 -0
- backtestingfx-0.2.0.dist-info/sboms/backtestingfx.cyclonedx.json +903 -0
|
Binary file
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from backtestingfx import _backtestingfx as _rust # type: ignore
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _DataView:
|
|
10
|
+
# ponytail: a window over the full bars list, visible up to the current bar.
|
|
11
|
+
# Slicing copies only the requested slice, not the growing prefix — this is
|
|
12
|
+
# what keeps data access O(1) per bar instead of O(n) (was O(n^2) overall).
|
|
13
|
+
__slots__ = ("_bars", "_len")
|
|
14
|
+
|
|
15
|
+
def __init__(self, bars):
|
|
16
|
+
self._bars = bars
|
|
17
|
+
self._len = 0
|
|
18
|
+
|
|
19
|
+
def __len__(self):
|
|
20
|
+
return self._len
|
|
21
|
+
|
|
22
|
+
def __getitem__(self, i):
|
|
23
|
+
if isinstance(i, slice):
|
|
24
|
+
return [self._bars[k] for k in range(*i.indices(self._len))]
|
|
25
|
+
if i < 0:
|
|
26
|
+
i += self._len
|
|
27
|
+
if not 0 <= i < self._len:
|
|
28
|
+
raise IndexError("bar index out of range")
|
|
29
|
+
return self._bars[i]
|
|
30
|
+
|
|
31
|
+
def __iter__(self):
|
|
32
|
+
return (self._bars[k] for k in range(self._len))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Strategy:
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._bars: Any = None
|
|
38
|
+
self._bar: Any = None
|
|
39
|
+
self._broker: Any = None
|
|
40
|
+
self._index: int = 0
|
|
41
|
+
self._data_view: Any = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def positions(self):
|
|
45
|
+
return self._broker.positions() if self._broker else []
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def data(self):
|
|
49
|
+
if not self._bars:
|
|
50
|
+
return []
|
|
51
|
+
if self._data_view is None:
|
|
52
|
+
self._data_view = _DataView(self._bars)
|
|
53
|
+
self._data_view._len = self._index + 1
|
|
54
|
+
return self._data_view
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def index(self):
|
|
58
|
+
return self._index
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def cash(self):
|
|
62
|
+
return self._broker.cash if self._broker else 0.0
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def equity(self):
|
|
66
|
+
return self._broker.equity(self._bar.close) if self._broker else 0.0
|
|
67
|
+
|
|
68
|
+
def init(self):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def next(self):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def buy(self, lot_size, stop_loss=None, take_profit=None):
|
|
75
|
+
self._broker.buy(
|
|
76
|
+
self._bar.close, lot_size, self._bar.timestamp, stop_loss, take_profit
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def sell(self, lot_size, stop_loss=None, take_profit=None):
|
|
80
|
+
self._broker.sell(
|
|
81
|
+
self._bar.close, lot_size, self._bar.timestamp, stop_loss, take_profit
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def close_all(self):
|
|
85
|
+
self._broker.close_all(self._bar.close, self._bar.timestamp)
|
|
86
|
+
|
|
87
|
+
def close_position(self, id):
|
|
88
|
+
self._broker.close_position(id, self._bar.close, self._bar.timestamp)
|
|
89
|
+
|
|
90
|
+
def close_partial(self, id, lot_size):
|
|
91
|
+
"""Close `lot_size` lots of a position, leaving the rest open."""
|
|
92
|
+
self._broker.close_partial(id, lot_size, self._bar.close, self._bar.timestamp)
|
|
93
|
+
|
|
94
|
+
def update_sl(self, id, stop_loss):
|
|
95
|
+
"""Move a position's stop loss. Returns False if there is no such position."""
|
|
96
|
+
return self._broker.update_sl(id, stop_loss)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Adapter:
|
|
100
|
+
def __init__(self, strategy):
|
|
101
|
+
self._strategy = strategy
|
|
102
|
+
self._index = 0
|
|
103
|
+
|
|
104
|
+
def init(self, bars):
|
|
105
|
+
self._strategy._bars = bars
|
|
106
|
+
self._strategy.init()
|
|
107
|
+
|
|
108
|
+
def next(self, bar, broker):
|
|
109
|
+
self._strategy._bar = bar
|
|
110
|
+
self._strategy._broker = broker
|
|
111
|
+
self._strategy._index = self._index
|
|
112
|
+
self._index += 1
|
|
113
|
+
self._strategy.next()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Backtest:
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
df,
|
|
120
|
+
strategy_class=None, # optional: optimize() uses a signal function instead
|
|
121
|
+
cash=10000.0,
|
|
122
|
+
commission=0.0,
|
|
123
|
+
spread=0.0,
|
|
124
|
+
contract_size=100000.0,
|
|
125
|
+
quote_to_account=1.0,
|
|
126
|
+
):
|
|
127
|
+
self._df = df
|
|
128
|
+
self._strategy_class = strategy_class
|
|
129
|
+
self._cash = cash
|
|
130
|
+
self._commission = commission
|
|
131
|
+
self._spread = spread
|
|
132
|
+
self._contract_size = contract_size
|
|
133
|
+
self._quote_to_account = quote_to_account
|
|
134
|
+
self._stats = None
|
|
135
|
+
self._report_df = None
|
|
136
|
+
|
|
137
|
+
def _to_bars(self, df):
|
|
138
|
+
required = {"open", "high", "low", "close"}
|
|
139
|
+
missing = required - set(df.columns.str.lower())
|
|
140
|
+
if missing:
|
|
141
|
+
raise ValueError(f"DataFrame missing required columns: {sorted(missing)}")
|
|
142
|
+
|
|
143
|
+
bars = []
|
|
144
|
+
for idx, row in df.iterrows():
|
|
145
|
+
if isinstance(idx, pd.Timestamp):
|
|
146
|
+
ts = int(idx.timestamp())
|
|
147
|
+
else:
|
|
148
|
+
ts = int(pd.Timestamp(row["timestamp"]).timestamp()) # type: ignore
|
|
149
|
+
|
|
150
|
+
bars.append(
|
|
151
|
+
_rust.Bar( # type: ignore
|
|
152
|
+
timestamp=ts,
|
|
153
|
+
open=float(row["open"]),
|
|
154
|
+
high=float(row["high"]),
|
|
155
|
+
low=float(row["low"]),
|
|
156
|
+
close=float(row["close"]),
|
|
157
|
+
volume=float(row.get("volume", 0.0)),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return bars
|
|
161
|
+
|
|
162
|
+
def _engine(self, df):
|
|
163
|
+
return _rust.Engine( # type: ignore
|
|
164
|
+
self._to_bars(df),
|
|
165
|
+
self._cash,
|
|
166
|
+
self._commission,
|
|
167
|
+
self._spread,
|
|
168
|
+
self._contract_size,
|
|
169
|
+
self._quote_to_account,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def run(self):
|
|
173
|
+
if self._strategy_class is None:
|
|
174
|
+
raise ValueError("Backtest needs a strategy_class to run(); use optimize() for signal functions")
|
|
175
|
+
|
|
176
|
+
self._stats = None
|
|
177
|
+
self._report_df = None
|
|
178
|
+
report_df = self._df.copy(deep=True)
|
|
179
|
+
engine = self._engine(report_df)
|
|
180
|
+
strategy = self._strategy_class()
|
|
181
|
+
self._stats = engine.run(_Adapter(strategy))
|
|
182
|
+
self._report_df = report_df
|
|
183
|
+
return self._stats
|
|
184
|
+
|
|
185
|
+
def optimize(self, signal_fn, maximize="total_return_pct", **grid):
|
|
186
|
+
"""Grid-search `signal_fn` over the given parameter ranges. Best result first.
|
|
187
|
+
|
|
188
|
+
`signal_fn(df, **params)` is called once per combination and returns one target
|
|
189
|
+
lot size per bar: positive for long, negative for short, 0.0 for flat. Write it
|
|
190
|
+
vectorised (pandas/numpy) — it runs in Python, but only once per combination,
|
|
191
|
+
never per bar. The simulations themselves run in parallel Rust threads.
|
|
192
|
+
|
|
193
|
+
Returns a list of `(params, stats)` sorted by the named Stats field, so
|
|
194
|
+
`results[0]` is the best run. Sort it yourself to minimise something instead.
|
|
195
|
+
"""
|
|
196
|
+
if not grid:
|
|
197
|
+
raise ValueError("optimize needs at least one parameter range")
|
|
198
|
+
|
|
199
|
+
names = list(grid)
|
|
200
|
+
combos = [dict(zip(names, values)) for values in itertools.product(*grid.values())]
|
|
201
|
+
|
|
202
|
+
signals = []
|
|
203
|
+
for combo in combos:
|
|
204
|
+
signal = np.asarray(signal_fn(self._df, **combo), dtype=float)
|
|
205
|
+
if np.isnan(signal).any():
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"signal_fn returned NaN for {combo} — indicator warmup should "
|
|
208
|
+
"produce 0.0 (flat), not NaN"
|
|
209
|
+
)
|
|
210
|
+
# ponytail: tolist() is the cheap bridge into Rust. It costs one Python
|
|
211
|
+
# float per bar per combo; swap in the `numpy` crate for a zero-copy
|
|
212
|
+
# PyReadonlyArray1 if this ever shows up in a profile.
|
|
213
|
+
signals.append(signal.tolist())
|
|
214
|
+
|
|
215
|
+
engine = self._engine(self._df)
|
|
216
|
+
results = _rust.run_grid(engine, signals) # type: ignore
|
|
217
|
+
|
|
218
|
+
return sorted(
|
|
219
|
+
zip(combos, results),
|
|
220
|
+
key=lambda pair: getattr(pair[1], maximize),
|
|
221
|
+
reverse=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def plot(self, filename="backtest.html", open_browser=True):
|
|
225
|
+
if self._stats is None:
|
|
226
|
+
raise RuntimeError("Run the backtest before plotting it")
|
|
227
|
+
|
|
228
|
+
from backtestingfx.plotting import render_report
|
|
229
|
+
|
|
230
|
+
return render_report(
|
|
231
|
+
self._report_df,
|
|
232
|
+
self._stats,
|
|
233
|
+
strategy_name=self._strategy_class.__name__,
|
|
234
|
+
filename=filename,
|
|
235
|
+
open_browser=open_browser,
|
|
236
|
+
commission=self._commission,
|
|
237
|
+
spread=self._spread,
|
|
238
|
+
contract_size=self._contract_size,
|
|
239
|
+
quote_to_account=self._quote_to_account,
|
|
240
|
+
)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import math
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_report(
|
|
10
|
+
data,
|
|
11
|
+
stats,
|
|
12
|
+
*,
|
|
13
|
+
strategy_name,
|
|
14
|
+
filename,
|
|
15
|
+
open_browser,
|
|
16
|
+
commission,
|
|
17
|
+
spread,
|
|
18
|
+
contract_size,
|
|
19
|
+
quote_to_account,
|
|
20
|
+
):
|
|
21
|
+
try:
|
|
22
|
+
import plotly.graph_objects as go
|
|
23
|
+
import plotly.io as pio
|
|
24
|
+
from plotly.subplots import make_subplots
|
|
25
|
+
except ModuleNotFoundError as error:
|
|
26
|
+
raise ModuleNotFoundError(
|
|
27
|
+
'Plotting requires Plotly. Install it with: pip install "backtestingfx[report]"'
|
|
28
|
+
) from error
|
|
29
|
+
|
|
30
|
+
if data.empty:
|
|
31
|
+
raise ValueError("Cannot plot a backtest with no bars")
|
|
32
|
+
|
|
33
|
+
columns = {str(column).lower(): column for column in data.columns}
|
|
34
|
+
if isinstance(data.index, pd.DatetimeIndex):
|
|
35
|
+
timestamps = pd.to_datetime(data.index, utc=True)
|
|
36
|
+
else:
|
|
37
|
+
timestamps = pd.to_datetime(data[columns["timestamp"]], utc=True)
|
|
38
|
+
|
|
39
|
+
equity = list(stats.equity_curve)
|
|
40
|
+
if len(equity) == len(data) + 1:
|
|
41
|
+
equity = equity[1:]
|
|
42
|
+
if len(equity) != len(data):
|
|
43
|
+
raise ValueError("Equity curve does not match the number of bars")
|
|
44
|
+
|
|
45
|
+
equity_series = pd.Series(equity, dtype=float)
|
|
46
|
+
peaks = pd.Series([stats.initial_cash, *equity], dtype=float).cummax().iloc[1:]
|
|
47
|
+
peaks.index = equity_series.index
|
|
48
|
+
drawdown = ((equity_series / peaks) - 1.0).fillna(0.0) * 100.0
|
|
49
|
+
trades = list(stats.trades)
|
|
50
|
+
|
|
51
|
+
chart = make_subplots(
|
|
52
|
+
rows=3,
|
|
53
|
+
cols=1,
|
|
54
|
+
shared_xaxes=True,
|
|
55
|
+
vertical_spacing=0.045,
|
|
56
|
+
row_heights=[0.58, 0.24, 0.18],
|
|
57
|
+
)
|
|
58
|
+
chart.add_trace(
|
|
59
|
+
go.Candlestick(
|
|
60
|
+
x=timestamps,
|
|
61
|
+
open=data[columns["open"]],
|
|
62
|
+
high=data[columns["high"]],
|
|
63
|
+
low=data[columns["low"]],
|
|
64
|
+
close=data[columns["close"]],
|
|
65
|
+
name="Price",
|
|
66
|
+
increasing_line_color="#45d483",
|
|
67
|
+
decreasing_line_color="#ff6b57",
|
|
68
|
+
),
|
|
69
|
+
row=1,
|
|
70
|
+
col=1,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
entry_lines_x = []
|
|
74
|
+
entry_lines_y = []
|
|
75
|
+
for trade in trades:
|
|
76
|
+
entry_lines_x.extend(
|
|
77
|
+
[
|
|
78
|
+
pd.to_datetime(trade.entry_timestamp, unit="s", utc=True),
|
|
79
|
+
pd.to_datetime(trade.exit_timestamp, unit="s", utc=True),
|
|
80
|
+
None,
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
entry_lines_y.extend([trade.entry_price, trade.exit_price, None])
|
|
84
|
+
if trades:
|
|
85
|
+
chart.add_trace(
|
|
86
|
+
go.Scatter(
|
|
87
|
+
x=entry_lines_x,
|
|
88
|
+
y=entry_lines_y,
|
|
89
|
+
mode="lines",
|
|
90
|
+
line={"color": "rgba(190, 190, 190, 0.28)", "width": 1},
|
|
91
|
+
hoverinfo="skip",
|
|
92
|
+
showlegend=False,
|
|
93
|
+
),
|
|
94
|
+
row=1,
|
|
95
|
+
col=1,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for is_long, label, color, symbol in (
|
|
99
|
+
(True, "Long entry", "#45d483", "triangle-up"),
|
|
100
|
+
(False, "Short entry", "#ff6b57", "triangle-down"),
|
|
101
|
+
):
|
|
102
|
+
matching = [trade for trade in trades if trade.is_long == is_long]
|
|
103
|
+
if matching:
|
|
104
|
+
chart.add_trace(
|
|
105
|
+
go.Scatter(
|
|
106
|
+
x=[pd.to_datetime(t.entry_timestamp, unit="s", utc=True) for t in matching],
|
|
107
|
+
y=[t.entry_price for t in matching],
|
|
108
|
+
mode="markers",
|
|
109
|
+
name=label,
|
|
110
|
+
marker={"color": color, "size": 11, "symbol": symbol},
|
|
111
|
+
customdata=[[t.lot_size, t.pnl] for t in matching],
|
|
112
|
+
hovertemplate=(
|
|
113
|
+
f"{label}<br>%{{x}}<br>Price %{{y:.5f}}"
|
|
114
|
+
"<br>Lots %{customdata[0]:.2f}<br>Net PnL %{customdata[1]:.2f}<extra></extra>"
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
row=1,
|
|
118
|
+
col=1,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if trades:
|
|
122
|
+
chart.add_trace(
|
|
123
|
+
go.Scatter(
|
|
124
|
+
x=[pd.to_datetime(t.exit_timestamp, unit="s", utc=True) for t in trades],
|
|
125
|
+
y=[t.exit_price for t in trades],
|
|
126
|
+
mode="markers",
|
|
127
|
+
name="Exit",
|
|
128
|
+
marker={
|
|
129
|
+
"color": ["#45d483" if t.pnl >= 0 else "#ff6b57" for t in trades],
|
|
130
|
+
"line": {"color": "#080808", "width": 1},
|
|
131
|
+
"size": 9,
|
|
132
|
+
"symbol": "circle",
|
|
133
|
+
},
|
|
134
|
+
customdata=[[t.pnl] for t in trades],
|
|
135
|
+
hovertemplate=(
|
|
136
|
+
"Exit<br>%{x}<br>Price %{y:.5f}"
|
|
137
|
+
"<br>Net PnL %{customdata[0]:.2f}<extra></extra>"
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
row=1,
|
|
141
|
+
col=1,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
chart.add_trace(
|
|
145
|
+
go.Scatter(
|
|
146
|
+
x=timestamps,
|
|
147
|
+
y=equity,
|
|
148
|
+
mode="lines",
|
|
149
|
+
name="Equity",
|
|
150
|
+
line={"color": "#f1c75b", "width": 2},
|
|
151
|
+
hovertemplate="%{x}<br>Equity %{y:,.2f}<extra></extra>",
|
|
152
|
+
),
|
|
153
|
+
row=2,
|
|
154
|
+
col=1,
|
|
155
|
+
)
|
|
156
|
+
chart.add_hline(
|
|
157
|
+
y=stats.initial_cash,
|
|
158
|
+
line={"color": "rgba(255,255,255,0.22)", "dash": "dot"},
|
|
159
|
+
row=2,
|
|
160
|
+
col=1,
|
|
161
|
+
)
|
|
162
|
+
chart.add_trace(
|
|
163
|
+
go.Scatter(
|
|
164
|
+
x=timestamps,
|
|
165
|
+
y=drawdown,
|
|
166
|
+
mode="lines",
|
|
167
|
+
name="Drawdown",
|
|
168
|
+
line={"color": "#ff6b57", "width": 1.5},
|
|
169
|
+
fill="tozeroy",
|
|
170
|
+
fillcolor="rgba(255, 107, 87, 0.20)",
|
|
171
|
+
hovertemplate="%{x}<br>Drawdown %{y:.2f}%<extra></extra>",
|
|
172
|
+
),
|
|
173
|
+
row=3,
|
|
174
|
+
col=1,
|
|
175
|
+
)
|
|
176
|
+
chart.update_layout(
|
|
177
|
+
height=920,
|
|
178
|
+
margin={"l": 60, "r": 25, "t": 35, "b": 35},
|
|
179
|
+
paper_bgcolor="#111111",
|
|
180
|
+
plot_bgcolor="#111111",
|
|
181
|
+
font={"color": "#d0d0d0", "family": "IBM Plex Mono, ui-monospace, monospace"},
|
|
182
|
+
hovermode="x unified",
|
|
183
|
+
legend={"orientation": "h", "y": 1.03, "x": 0},
|
|
184
|
+
xaxis_rangeslider_visible=False,
|
|
185
|
+
)
|
|
186
|
+
chart.update_xaxes(gridcolor="rgba(255,255,255,0.06)", showspikes=True)
|
|
187
|
+
chart.update_yaxes(gridcolor="rgba(255,255,255,0.06)", zeroline=False)
|
|
188
|
+
chart.update_yaxes(title_text="Price", row=1, col=1)
|
|
189
|
+
chart.update_yaxes(title_text="Equity", row=2, col=1)
|
|
190
|
+
chart.update_yaxes(title_text="Drawdown %", row=3, col=1)
|
|
191
|
+
|
|
192
|
+
analytics = make_subplots(
|
|
193
|
+
rows=1,
|
|
194
|
+
cols=2,
|
|
195
|
+
subplot_titles=("Trade PnL distribution", "Cumulative realized PnL"),
|
|
196
|
+
horizontal_spacing=0.12,
|
|
197
|
+
)
|
|
198
|
+
pnls = [trade.pnl for trade in trades]
|
|
199
|
+
if pnls:
|
|
200
|
+
analytics.add_trace(
|
|
201
|
+
go.Histogram(x=pnls, marker_color="#9da3ad", name="Trade PnL"),
|
|
202
|
+
row=1,
|
|
203
|
+
col=1,
|
|
204
|
+
)
|
|
205
|
+
cumulative = pd.Series(pnls).cumsum()
|
|
206
|
+
analytics.add_trace(
|
|
207
|
+
go.Scatter(
|
|
208
|
+
x=list(range(1, len(pnls) + 1)),
|
|
209
|
+
y=cumulative,
|
|
210
|
+
mode="lines+markers",
|
|
211
|
+
line={"color": "#45d483", "width": 2},
|
|
212
|
+
marker={"size": 5},
|
|
213
|
+
name="Cumulative PnL",
|
|
214
|
+
),
|
|
215
|
+
row=1,
|
|
216
|
+
col=2,
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
analytics.add_annotation(
|
|
220
|
+
text="No completed trades",
|
|
221
|
+
x=0.5,
|
|
222
|
+
y=0.5,
|
|
223
|
+
xref="paper",
|
|
224
|
+
yref="paper",
|
|
225
|
+
showarrow=False,
|
|
226
|
+
)
|
|
227
|
+
analytics.update_layout(
|
|
228
|
+
height=390,
|
|
229
|
+
margin={"l": 55, "r": 25, "t": 55, "b": 45},
|
|
230
|
+
paper_bgcolor="#111111",
|
|
231
|
+
plot_bgcolor="#111111",
|
|
232
|
+
font={"color": "#d0d0d0", "family": "IBM Plex Mono, ui-monospace, monospace"},
|
|
233
|
+
showlegend=False,
|
|
234
|
+
)
|
|
235
|
+
analytics.update_xaxes(gridcolor="rgba(255,255,255,0.06)")
|
|
236
|
+
analytics.update_yaxes(gridcolor="rgba(255,255,255,0.06)", zeroline=False)
|
|
237
|
+
|
|
238
|
+
def number(value, suffix="", money=False):
|
|
239
|
+
if math.isinf(value):
|
|
240
|
+
return "∞"
|
|
241
|
+
prefix = "$" if money else ""
|
|
242
|
+
return f"{prefix}{value:,.2f}{suffix}"
|
|
243
|
+
|
|
244
|
+
metric_values = (
|
|
245
|
+
("Total return", number(stats.total_return_pct, "%")),
|
|
246
|
+
("Final equity", number(stats.final_cash, money=True)),
|
|
247
|
+
("Max drawdown", number(stats.max_drawdown_pct, "%")),
|
|
248
|
+
("Sharpe", number(stats.sharpe_ratio)),
|
|
249
|
+
("Trades", str(stats.num_trades)),
|
|
250
|
+
("Win rate", number(stats.win_rate_pct, "%")),
|
|
251
|
+
("Profit factor", number(stats.profit_factor)),
|
|
252
|
+
("Average trade", number(stats.avg_pnl, money=True)),
|
|
253
|
+
)
|
|
254
|
+
metrics_html = "".join(
|
|
255
|
+
f'<div class="metric"><span>{label}</span><strong>{value}</strong></div>'
|
|
256
|
+
for label, value in metric_values
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
rows = []
|
|
260
|
+
for trade in trades:
|
|
261
|
+
entry_time = pd.to_datetime(trade.entry_timestamp, unit="s", utc=True)
|
|
262
|
+
exit_time = pd.to_datetime(trade.exit_timestamp, unit="s", utc=True)
|
|
263
|
+
duration = exit_time - entry_time
|
|
264
|
+
result_class = "positive" if trade.pnl >= 0 else "negative"
|
|
265
|
+
rows.append(
|
|
266
|
+
"<tr>"
|
|
267
|
+
f"<td>{'LONG' if trade.is_long else 'SHORT'}</td>"
|
|
268
|
+
f"<td>{html.escape(entry_time.strftime('%Y-%m-%d %H:%M'))}</td>"
|
|
269
|
+
f"<td>{html.escape(exit_time.strftime('%Y-%m-%d %H:%M'))}</td>"
|
|
270
|
+
f"<td>{trade.entry_price:.5f}</td>"
|
|
271
|
+
f"<td>{trade.exit_price:.5f}</td>"
|
|
272
|
+
f"<td>{trade.lot_size:.2f}</td>"
|
|
273
|
+
f'<td class="{result_class}">{trade.pnl:,.2f}</td>'
|
|
274
|
+
f"<td>{html.escape(str(duration))}</td>"
|
|
275
|
+
"</tr>"
|
|
276
|
+
)
|
|
277
|
+
if not rows:
|
|
278
|
+
rows.append('<tr><td colspan="8" class="empty">No completed trades</td></tr>')
|
|
279
|
+
|
|
280
|
+
config = {"displaylogo": False, "responsive": True, "scrollZoom": True}
|
|
281
|
+
chart_html = pio.to_html(
|
|
282
|
+
chart,
|
|
283
|
+
full_html=False,
|
|
284
|
+
include_plotlyjs=True,
|
|
285
|
+
config=config,
|
|
286
|
+
)
|
|
287
|
+
analytics_html = pio.to_html(
|
|
288
|
+
analytics,
|
|
289
|
+
full_html=False,
|
|
290
|
+
include_plotlyjs=False,
|
|
291
|
+
config=config,
|
|
292
|
+
)
|
|
293
|
+
start = timestamps[0].strftime("%Y-%m-%d %H:%M UTC")
|
|
294
|
+
end = timestamps[-1].strftime("%Y-%m-%d %H:%M UTC")
|
|
295
|
+
safe_strategy_name = html.escape(strategy_name)
|
|
296
|
+
|
|
297
|
+
document = f"""<!doctype html>
|
|
298
|
+
<html lang="en">
|
|
299
|
+
<head>
|
|
300
|
+
<meta charset="utf-8">
|
|
301
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
302
|
+
<title>{safe_strategy_name} | backtestingfx report</title>
|
|
303
|
+
<style>
|
|
304
|
+
:root {{ color-scheme: dark; --bg: #080808; --panel: #111111; --line: #2b2b2b; --ink: #f1f1f1; --muted: #929292; --accent: #d8d8d8; --green: #45d483; --red: #ff6b57; --gold: #f1c75b; }}
|
|
305
|
+
* {{ box-sizing: border-box; }}
|
|
306
|
+
body {{ margin: 0; background: var(--bg); color: var(--ink); font-family: Inter, ui-sans-serif, system-ui, sans-serif; }}
|
|
307
|
+
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%); }}
|
|
308
|
+
main {{ width: min(1500px, calc(100% - 40px)); margin: 0 auto; padding: 38px 0 70px; position: relative; }}
|
|
309
|
+
header {{ display: flex; justify-content: space-between; gap: 30px; align-items: end; padding: 8px 0 28px; border-bottom: 1px solid var(--line); }}
|
|
310
|
+
.eyebrow {{ color: var(--accent); font: 700 12px/1.4 ui-monospace, monospace; letter-spacing: .18em; text-transform: uppercase; }}
|
|
311
|
+
h1 {{ margin: 8px 0 14px; font-size: clamp(34px, 4.5vw, 64px); line-height: 1.08; letter-spacing: -.045em; overflow-wrap: anywhere; }}
|
|
312
|
+
.period {{ color: var(--muted); font: 13px/1.6 ui-monospace, monospace; }}
|
|
313
|
+
.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; }}
|
|
314
|
+
.metrics {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 28px 0; background: var(--line); border: 1px solid var(--line); }}
|
|
315
|
+
.metric {{ background: var(--panel); padding: 18px 20px; min-height: 98px; display: flex; flex-direction: column; justify-content: space-between; }}
|
|
316
|
+
.metric span, .section-label {{ color: var(--muted); font: 700 10px ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }}
|
|
317
|
+
.metric strong {{ font: 600 clamp(21px, 2vw, 31px) ui-monospace, monospace; letter-spacing: -.04em; }}
|
|
318
|
+
.panel {{ background: var(--panel); border: 1px solid var(--line); margin-top: 18px; overflow: hidden; }}
|
|
319
|
+
.panel-head {{ display: flex; justify-content: space-between; align-items: center; padding: 18px 22px; border-bottom: 1px solid var(--line); }}
|
|
320
|
+
.panel-head h2 {{ margin: 0; font-size: 17px; letter-spacing: -.02em; }}
|
|
321
|
+
.assumptions {{ display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); }}
|
|
322
|
+
.assumption {{ padding: 15px 20px; border-right: 1px solid var(--line); }}
|
|
323
|
+
.assumption:last-child {{ border-right: 0; }}
|
|
324
|
+
.assumption span {{ display: block; color: var(--muted); font: 10px ui-monospace, monospace; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 5px; }}
|
|
325
|
+
.assumption strong {{ font: 14px ui-monospace, monospace; }}
|
|
326
|
+
.table-wrap {{ overflow-x: auto; }}
|
|
327
|
+
table {{ width: 100%; border-collapse: collapse; font: 12px ui-monospace, monospace; }}
|
|
328
|
+
th {{ color: var(--muted); text-align: left; font-size: 10px; letter-spacing: .09em; text-transform: uppercase; }}
|
|
329
|
+
th, td {{ padding: 13px 16px; border-bottom: 1px solid var(--line); white-space: nowrap; }}
|
|
330
|
+
tbody tr:hover {{ background: rgba(255,255,255,.025); }}
|
|
331
|
+
.positive {{ color: var(--green); }} .negative {{ color: var(--red); }} .empty {{ color: var(--muted); text-align: center; padding: 30px; }}
|
|
332
|
+
footer {{ color: var(--muted); display: flex; justify-content: space-between; margin-top: 28px; font: 10px ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }}
|
|
333
|
+
@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; }} }}
|
|
334
|
+
@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; }} }}
|
|
335
|
+
</style>
|
|
336
|
+
</head>
|
|
337
|
+
<body>
|
|
338
|
+
<main>
|
|
339
|
+
<header>
|
|
340
|
+
<div><div class="eyebrow">backtestingfx / strategy report</div><h1>{safe_strategy_name}</h1><div class="period">{start} → {end} / {len(data):,} bars</div></div>
|
|
341
|
+
<div class="status">RUN COMPLETE</div>
|
|
342
|
+
</header>
|
|
343
|
+
<section class="metrics">{metrics_html}</section>
|
|
344
|
+
<section class="panel">
|
|
345
|
+
<div class="panel-head"><h2>Market replay</h2><span class="section-label">Price / Equity / Drawdown</span></div>
|
|
346
|
+
{chart_html}
|
|
347
|
+
<div class="assumptions">
|
|
348
|
+
<div class="assumption"><span>Commission / lot / side</span><strong>{commission:,.4f}</strong></div>
|
|
349
|
+
<div class="assumption"><span>Spread offset</span><strong>{spread:,.5f}</strong></div>
|
|
350
|
+
<div class="assumption"><span>Contract size</span><strong>{contract_size:,.0f}</strong></div>
|
|
351
|
+
<div class="assumption"><span>Quote conversion</span><strong>{quote_to_account:,.5f}</strong></div>
|
|
352
|
+
</div>
|
|
353
|
+
</section>
|
|
354
|
+
<section class="panel"><div class="panel-head"><h2>Trade diagnostics</h2><span class="section-label">Distribution / Sequence</span></div>{analytics_html}</section>
|
|
355
|
+
<section class="panel">
|
|
356
|
+
<div class="panel-head"><h2>Trade ledger</h2><span class="section-label">{len(trades)} completed</span></div>
|
|
357
|
+
<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>
|
|
358
|
+
</section>
|
|
359
|
+
<footer><span>Generated by backtestingfx</span><span>Research output, not financial advice</span></footer>
|
|
360
|
+
</main>
|
|
361
|
+
</body>
|
|
362
|
+
</html>"""
|
|
363
|
+
|
|
364
|
+
output = Path(filename).expanduser().resolve()
|
|
365
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
output.write_text(document, encoding="utf-8")
|
|
367
|
+
if open_browser:
|
|
368
|
+
webbrowser.open(output.as_uri())
|
|
369
|
+
return str(output)
|