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.
@@ -0,0 +1,2 @@
1
+ from backtestingfx._backtestingfx import Bar, Broker, Engine, Stats, Trade # type: ignore
2
+ from backtestingfx.backtest import Strategy, Backtest
@@ -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 "&infin;"
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} &rarr; {end} &nbsp; / &nbsp; {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)