|
32 | 32 | rank_index, |
33 | 33 | format_table, |
34 | 34 | ) |
| 35 | +from investing_algorithm_framework.services.backtest_store import ( |
| 36 | + LocalDirStore, |
| 37 | +) |
| 38 | +from investing_algorithm_framework import BacktestReport |
35 | 39 |
|
36 | 40 |
|
37 | 41 | # Directory-format fixture shipped with the test suite. We use it |
@@ -109,6 +113,103 @@ def _row(label: str, value, fmt: str = "") -> None: |
109 | 113 | ) |
110 | 114 |
|
111 | 115 |
|
| 116 | +def _print_backtest_full_report(bt: Backtest) -> None: |
| 117 | + """Render a detailed multi-section report for a single backtest. |
| 118 | +
|
| 119 | + Goes beyond :func:`_print_backtest_report` by walking the |
| 120 | + per-run breakdown, listing positions, and showing the first few |
| 121 | + trades/orders so the demo's "open the winner" step actually |
| 122 | + looks like a backtest report and not just a summary row. |
| 123 | + """ |
| 124 | + _print_backtest_report(bt) |
| 125 | + |
| 126 | + runs = bt.backtest_runs or [] |
| 127 | + if not runs: |
| 128 | + return |
| 129 | + |
| 130 | + # ------------------- per-run breakdown ------------------- |
| 131 | + print("\n --- per-run breakdown ---") |
| 132 | + header = ( |
| 133 | + f" {'#':>2} {'window':<20} {'days':>5} {'orders':>7} " |
| 134 | + f"{'trades':>7} {'positions':>10} {'final_value':>14}" |
| 135 | + ) |
| 136 | + print(header) |
| 137 | + print(" " + "-" * (len(header) - 2)) |
| 138 | + for i, r in enumerate(runs, start=1): |
| 139 | + window = ( |
| 140 | + r.backtest_date_range_name |
| 141 | + if r.backtest_date_range_name |
| 142 | + else f"{r.backtest_start_date.date()}..{r.backtest_end_date.date()}" |
| 143 | + )[:20] |
| 144 | + m = r.backtest_metrics |
| 145 | + final = ( |
| 146 | + f"{float(m.final_value):.2f}" |
| 147 | + if m is not None and getattr(m, "final_value", None) is not None |
| 148 | + else "n/a" |
| 149 | + ) |
| 150 | + print( |
| 151 | + f" {i:>2} {window:<20} {r.number_of_days:>5} " |
| 152 | + f"{r.number_of_orders:>7} {r.number_of_trades:>7} " |
| 153 | + f"{r.number_of_positions:>10} {final:>14}" |
| 154 | + ) |
| 155 | + |
| 156 | + # ------------------- positions snapshot ------------------- |
| 157 | + last = runs[-1] |
| 158 | + positions = list(getattr(last, "positions", None) or []) |
| 159 | + if positions: |
| 160 | + print(f"\n --- positions (last run, {len(positions)}) ---") |
| 161 | + for p in positions[:10]: |
| 162 | + symbol = getattr(p, "symbol", None) or "?" |
| 163 | + amount = getattr(p, "amount", None) |
| 164 | + cost = getattr(p, "cost", None) |
| 165 | + print( |
| 166 | + f" {symbol:<14} amount={amount} cost={cost}" |
| 167 | + ) |
| 168 | + if len(positions) > 10: |
| 169 | + print(f" ... and {len(positions) - 10} more") |
| 170 | + |
| 171 | + # ------------------- trades preview ------------------- |
| 172 | + trades = list(getattr(last, "trades", None) or []) |
| 173 | + if trades: |
| 174 | + print(f"\n --- trades (last run, {len(trades)} total, first 5) ---") |
| 175 | + for t in trades[:5]: |
| 176 | + print( |
| 177 | + f" {getattr(t, 'symbol', '?'):<10} " |
| 178 | + f"opened={getattr(t, 'opened_at', None)} " |
| 179 | + f"net_gain={getattr(t, 'net_gain', None)}" |
| 180 | + ) |
| 181 | + |
| 182 | + # ------------------- richer metrics from first run ------------------- |
| 183 | + m = runs[0].backtest_metrics |
| 184 | + if m is None: |
| 185 | + return |
| 186 | + print("\n --- additional risk / return metrics (first run) ---") |
| 187 | + extras = [ |
| 188 | + ("cagr", "cagr", ".4f"), |
| 189 | + ("annual_volatility", "annual_volatility", ".4f"), |
| 190 | + ("max_drawdown_abs", "max_drawdown_absolute", ".2f"), |
| 191 | + ("max_drawdown_dur", "max_drawdown_duration", ""), |
| 192 | + ("gross_profit", "gross_profit", ".2f"), |
| 193 | + ("gross_loss", "gross_loss", ".2f"), |
| 194 | + ("best_trade", "best_trade", ".2f"), |
| 195 | + ("max_consec_wins", "max_consecutive_wins", ""), |
| 196 | + ("max_consec_losses", "max_consecutive_losses", ""), |
| 197 | + ] |
| 198 | + for label, attr, fmt in extras: |
| 199 | + val = getattr(m, attr, None) |
| 200 | + if val is None: |
| 201 | + rendered = "n/a" |
| 202 | + elif fmt: |
| 203 | + try: |
| 204 | + rendered = format(float(val), fmt) |
| 205 | + except (TypeError, ValueError): |
| 206 | + rendered = str(val) |
| 207 | + else: |
| 208 | + rendered = str(val) |
| 209 | + print(f" {label:<22}: {rendered}") |
| 210 | + |
| 211 | + |
| 212 | + |
112 | 213 | def _seed_bundles(out_dir: Path, n: int = 6) -> None: |
113 | 214 | """Write ``n`` synthetic ``.iafbt`` bundles into *out_dir*.""" |
114 | 215 | if not TEMPLATE.is_dir(): |
@@ -240,6 +341,20 @@ def main() -> None: |
240 | 341 | winner_bt = Backtest.open(str(winner_path)) |
241 | 342 | _print_backtest_report(winner_bt) |
242 | 343 |
|
| 344 | + # ------------------------------------------------------------------ |
| 345 | + # 6b. Full backtest report — per-run breakdown, positions, trades |
| 346 | + # ------------------------------------------------------------------ |
| 347 | + _print_section( |
| 348 | + "6b. Full backtest report for the winner " |
| 349 | + "(runs / positions / trades)" |
| 350 | + ) |
| 351 | + print( |
| 352 | + "Same bundle, expanded view: per-run breakdown, end-of-backtest " |
| 353 | + "positions, the first few trades, and a richer slice of " |
| 354 | + "per-run risk/return metrics.\n" |
| 355 | + ) |
| 356 | + _print_backtest_full_report(winner_bt) |
| 357 | + |
243 | 358 | # ------------------------------------------------------------------ |
244 | 359 | # 7. Iterate the index and print a one-line report per backtest |
245 | 360 | # ------------------------------------------------------------------ |
@@ -272,16 +387,64 @@ def main() -> None: |
272 | 387 | print(f"\n--- [{i}] {r['algorithm_id']} ".ljust(72, "-")) |
273 | 388 | _print_backtest_report(bt) |
274 | 389 |
|
| 390 | + # ------------------------------------------------------------------ |
| 391 | + # 9. Storage layer -> HTML dashboard |
| 392 | + # ------------------------------------------------------------------ |
| 393 | + _print_section( |
| 394 | + "9. Storage layer -> HTML dashboard " |
| 395 | + "(rank in SQLite, load via store, render report)" |
| 396 | + ) |
| 397 | + print( |
| 398 | + "Wire the Tier-1 SQLite index, the Tier-2 LocalDirStore and the\n" |
| 399 | + "BacktestReport HTML dashboard end-to-end:\n" |
| 400 | + " 1. rank_index() picks the top-N bundles from SQLite alone\n" |
| 401 | + " (no Parquet metric blob is decoded yet).\n" |
| 402 | + " 2. LocalDirStore.open(handle) materialises just those\n" |
| 403 | + " bundles through the BacktestStore protocol.\n" |
| 404 | + " 3. BacktestReport(backtests=[...]).save(path) renders the\n" |
| 405 | + " interactive HTML dashboard with every loaded bundle.\n" |
| 406 | + ) |
| 407 | + store = LocalDirStore(work) |
| 408 | + print(f" store : {type(store).__name__}({work})") |
| 409 | + print(f" bundles in store: {len(store)}") |
| 410 | + |
| 411 | + top = rank_index(str(work), by="sharpe_ratio", limit=3) |
| 412 | + print(f" top-3 by sharpe: {[r['algorithm_id'] for r in top]}") |
| 413 | + |
| 414 | + loaded: list[Backtest] = [] |
| 415 | + for r in top: |
| 416 | + # The Tier-1 row's bundle_path is relative to the store root, |
| 417 | + # which is exactly what LocalDirStore uses as a handle. |
| 418 | + handle = r["bundle_path"] |
| 419 | + bt = store.open(handle) |
| 420 | + loaded.append(bt) |
| 421 | + print( |
| 422 | + f" store.open({handle!r}) -> {bt.algorithm_id} " |
| 423 | + f"({len(bt.backtest_runs)} run(s))" |
| 424 | + ) |
| 425 | + |
| 426 | + report = BacktestReport(backtests=loaded) |
| 427 | + html_path = work / "dashboard.html" |
| 428 | + report.save(str(html_path)) |
| 429 | + html_size_kb = os.path.getsize(html_path) / 1024.0 |
| 430 | + print( |
| 431 | + f"\n wrote HTML dashboard -> {html_path}\n" |
| 432 | + f" size: {html_size_kb:.1f} KB " |
| 433 | + f"(self-contained: CSS + JS + data inlined)\n" |
| 434 | + f" open it with: open {html_path}" |
| 435 | + ) |
| 436 | + |
275 | 437 | # ------------------------------------------------------------------ |
276 | 438 | # Wrap up |
277 | 439 | # ------------------------------------------------------------------ |
278 | 440 | _print_section("Done") |
279 | 441 | print( |
280 | | - "Bundles + index left in:\n" |
| 442 | + "Bundles + index + dashboard left in:\n" |
281 | 443 | f" {work}\n" |
282 | 444 | "Try the CLI directly:\n" |
283 | 445 | f" iaf list {work} --sort calmar_ratio --json\n" |
284 | 446 | f" iaf rank {work} --by sharpe_ratio -n 3\n" |
| 447 | + f" open {work / 'dashboard.html'}\n" |
285 | 448 | ) |
286 | 449 |
|
287 | 450 |
|
|
0 commit comments