Skip to content

Commit a271960

Browse files
authored
Merge pull request #516 from coding-kitties/fix/497-report-html-scaling
feat(backtesting): single-bundle binary persistence format (#487)
2 parents 0d0e99c + 1e0c64a commit a271960

27 files changed

Lines changed: 3504 additions & 1974 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ This framework is built around the full loop: **create strategies → vector bac
8686
- 🎯 **Return Scenario Projections** — Good, average, bad & very bad year projections from backtest data
8787
- 📉 **Benchmark Comparison** — Beat-rate analysis vs Buy & Hold, DCA, risk-free & custom benchmarks
8888
- 📄 **One-Click HTML Report** — Self-contained file, no server, dark & light theme, shareable
89+
- 📦 **Custom `.iafbt` Backtest Bundle Format** — An explicit, versioned, compressed, language-portable container (zstd + msgpack with magic-byte header) plus a separate parquet index for fast filtering without loading. ~21× smaller and ~27× fewer files than standard filebased directory layouts, with parallel I/O for fast load/save of large amounts of backtests.
8990
- 🌐 **Load External Data** — Fetch CSV, JSON, or Parquet from any URL with caching and auto-refresh
9091
-**[Record Custom Variables](https://coding-kitties.github.io/investing-algorithm-framework/Advanced%20Concepts/recording-variables)** — Track any indicator or metric during backtests with `context.record()`
9192
- �🚀 **Build → Backtest → Deploy** — Local dev, cloud deploy (AWS / Azure), or monetize on Finterion

docusaurus/docs/Getting Started/backtest-reports.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,17 @@ report = BacktestReport.open(directory_path="./my_backtests")
6060
report.show()
6161
```
6262

63-
The `open()` method recursively finds all valid backtest directories (containing `algorithm_id.json` and a `runs/` folder) and loads them into a single report.
63+
The `open()` method recursively finds all valid backtest directories (containing `algorithm_id.json` and a `runs/` folder) **and** any `.iafbt` bundle files, and loads them into a single report.
64+
65+
:::tip Optimized `.iafbt` bundle format
66+
Backtests are saved by default in the framework's custom **`.iafbt` bundle format** — a single binary file per backtest combining zstd compression and MessagePack encoding. It is purpose-built for backtest reports: ~21× smaller and ~27× fewer files than the legacy directory format, and `BacktestReport.open()` loads it ~3× faster. The legacy directory format is still fully supported for backwards compatibility, and you can mix both in the same folder.
67+
68+
For very large batches, opt into parallel loading:
69+
70+
```python
71+
report = BacktestReport.open(directory_path="./my_backtests", workers=4)
72+
```
73+
:::
6474

6575
You can also combine disk and in-memory backtests:
6676

docusaurus/docs/Getting Started/backtesting.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,49 @@ from investing_algorithm_framework import load_backtests_from_directory
8585
backtests = load_backtests_from_directory("./my_backtests")
8686
```
8787

88+
:::info `.iafbt` bundle format
89+
Backtests are persisted in the framework's optimized **`.iafbt` bundle format** — a single binary file per backtest using zstd compression + MessagePack encoding. Compared to the legacy directory layout it is ~21× smaller, ~27× fewer files, and ~3× faster to load. Both `save_backtests_to_directory` and `load_backtests_from_directory` support parallel I/O via `workers=N`. Existing legacy directories keep working transparently; use `iaf migrate-backtests --src ... --dst ...` to convert them.
90+
:::
91+
92+
### Migrating Existing Backtests
93+
94+
Convert a directory of legacy backtest folders to the new bundle
95+
format with `migrate_backtests`. The migration is **streamed**
96+
(load+save fused per worker), so memory usage stays roughly
97+
constant regardless of how many backtests you migrate, and an
98+
interrupted run can simply be re-invoked to resume — destination
99+
bundles that already exist are skipped by default.
100+
101+
```python
102+
from investing_algorithm_framework import migrate_backtests
103+
104+
n = migrate_backtests(
105+
src_dir="./old_backtests", # legacy folders and/or .iafbt
106+
dst_dir="./bundled_backtests", # destination
107+
workers=8, # parallel; None = min(8, cpu)
108+
show_progress=True,
109+
write_index=True, # also build index.parquet
110+
include_ohlcv=False,
111+
skip_existing=True, # resume-safe (default)
112+
delete_source=False, # set True to remove originals
113+
)
114+
print(f"migrated {n} backtests")
115+
```
116+
117+
Or from the CLI:
118+
119+
```bash
120+
iaf migrate-backtests \
121+
--src ./old_backtests \
122+
--dst ./bundled_backtests \
123+
--workers 8
124+
```
125+
126+
Pass `--delete-source` (or `delete_source=True` in Python) to remove
127+
each legacy directory/bundle after its destination has been written
128+
successfully. Sources are deleted per-backtest, so an interrupted
129+
run leaves only the not-yet-migrated ones behind.
130+
88131
## Reporting
89132

90133
Use [Backtest Reports](/docs/Getting%20Started/backtest-reports) to turn

investing_algorithm_framework/app/reporting/backtest_report.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from jinja2 import Environment, FileSystemLoader
1313

1414
from investing_algorithm_framework.domain import (
15-
Backtest, OperationalException, tqdm
15+
Backtest, OperationalException, tqdm, BUNDLE_EXT
1616
)
1717

1818
logger = logging.getLogger("investing_algorithm_framework")
@@ -217,9 +217,15 @@ def save(self, path):
217217

218218
@staticmethod
219219
def _is_backtest(backtest_path):
220+
if not os.path.exists(backtest_path):
221+
return False
222+
# Bundle file (.iafbt)
223+
if os.path.isfile(backtest_path) and \
224+
backtest_path.endswith(BUNDLE_EXT):
225+
return True
226+
# Legacy directory layout
220227
return (
221-
os.path.exists(backtest_path)
222-
and os.path.isdir(backtest_path)
228+
os.path.isdir(backtest_path)
223229
and os.path.isfile(
224230
os.path.join(backtest_path, "algorithm_id.json")
225231
)
@@ -231,6 +237,7 @@ def open(
231237
backtests: List[Backtest] = None,
232238
directory_path: Union[str, List[str], None] = None,
233239
show_progress: bool = False,
240+
workers: Union[int, None] = None,
234241
) -> "BacktestReport":
235242
loaded = []
236243
source_tags = []
@@ -254,7 +261,7 @@ def open(
254261
if BacktestReport._is_backtest(dp):
255262
backtest_paths.append((dp, tag))
256263
else:
257-
for root, dirs, _ in os.walk(dp):
264+
for root, dirs, files in os.walk(dp):
258265
for dir_name in dirs:
259266
subdir = os.path.join(
260267
root, dir_name
@@ -265,6 +272,11 @@ def open(
265272
backtest_paths.append(
266273
(subdir, tag)
267274
)
275+
for file_name in files:
276+
if file_name.endswith(BUNDLE_EXT):
277+
backtest_paths.append(
278+
(os.path.join(root, file_name), tag)
279+
)
268280

269281
iterator = backtest_paths
270282
if show_progress:
@@ -273,9 +285,43 @@ def open(
273285
desc="Loading backtests",
274286
)
275287

276-
for path, tag in iterator:
277-
loaded.append(Backtest.open(path))
278-
source_tags.append(tag)
288+
# Parallel load is opt-in (workers > 1). ProcessPoolExecutor
289+
# startup costs typically dwarf the per-backtest decode for
290+
# batches < ~30, so keep default behaviour serial. Pass
291+
# ``workers=N`` (or ``-1`` for cpu_count) to load large
292+
# batches in parallel.
293+
from investing_algorithm_framework.domain.backtesting.\
294+
backtest_utils import (
295+
_load_one_dispatch, _resolve_workers,
296+
)
297+
298+
n = len(backtest_paths)
299+
resolved_workers = (
300+
_resolve_workers(workers) if workers is not None else 1
301+
)
302+
if resolved_workers > 1 and n >= 4:
303+
from concurrent.futures import ProcessPoolExecutor
304+
305+
items = [
306+
(path, "bundle" if path.endswith(BUNDLE_EXT) else "dir")
307+
for path, _ in backtest_paths
308+
]
309+
with ProcessPoolExecutor(max_workers=resolved_workers) as ex:
310+
results = list(
311+
tqdm(
312+
ex.map(_load_one_dispatch, items),
313+
total=n,
314+
desc="Loading backtests",
315+
disable=not show_progress,
316+
)
317+
)
318+
for bt, (_, tag) in zip(results, backtest_paths):
319+
loaded.append(bt)
320+
source_tags.append(tag)
321+
else:
322+
for path, tag in iterator:
323+
loaded.append(Backtest.open(path))
324+
source_tags.append(tag)
279325

280326
for bt in backtests:
281327
if not isinstance(bt, Backtest):

investing_algorithm_framework/cli/cli.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,73 @@ def mcp(directory):
250250

251251

252252
cli.add_command(mcp)
253+
254+
255+
@click.command(name="migrate-backtests")
256+
@click.option(
257+
"--src", "-s",
258+
required=True,
259+
type=click.Path(exists=True, file_okay=False, dir_okay=True),
260+
help="Source directory containing legacy backtest sub-directories.",
261+
)
262+
@click.option(
263+
"--dst", "-d",
264+
required=True,
265+
type=click.Path(file_okay=False, dir_okay=True),
266+
help="Destination directory for the new ``.iafbt`` bundle files.",
267+
)
268+
@click.option(
269+
"--workers", "-w", type=int, default=None,
270+
help="Number of parallel workers (default: min(8, CPU count)).",
271+
)
272+
@click.option(
273+
"--no-index", is_flag=True, default=False,
274+
help="Skip writing index.parquet at the destination.",
275+
)
276+
@click.option(
277+
"--include-ohlcv", is_flag=True, default=False,
278+
help="Include OHLCV data in the destination bundles.",
279+
)
280+
@click.option(
281+
"--no-skip-existing", is_flag=True, default=False,
282+
help="Re-migrate even if the destination bundle already exists.",
283+
)
284+
@click.option(
285+
"--delete-source", is_flag=True, default=False,
286+
help=(
287+
"Delete each source directory/bundle after its destination "
288+
"has been written successfully. Use with care."
289+
),
290+
)
291+
def migrate_backtests_cmd(
292+
src, dst, workers, no_index, include_ohlcv, no_skip_existing,
293+
delete_source,
294+
):
295+
"""Convert a directory of legacy backtest folders into the bundled
296+
binary format introduced in issue #487.
297+
298+
The new ``.iafbt`` format is a single zstd-compressed MessagePack
299+
file per backtest. Loading bundled directories is dramatically
300+
faster than the legacy multi-file layout for large batches.
301+
302+
Migration is streamed (load+save fused per worker) so memory
303+
usage stays roughly constant regardless of source size, and
304+
interrupted runs can be resumed (existing destination bundles
305+
are skipped by default).
306+
"""
307+
from investing_algorithm_framework.domain import migrate_backtests
308+
309+
n = migrate_backtests(
310+
src,
311+
dst,
312+
workers=workers,
313+
show_progress=True,
314+
write_index=not no_index,
315+
include_ohlcv=include_ohlcv,
316+
skip_existing=not no_skip_existing,
317+
delete_source=delete_source,
318+
)
319+
click.echo(f"Migrated {n} backtest(s) from {src} to {dst}")
320+
321+
322+
cli.add_command(migrate_backtests_cmd)

investing_algorithm_framework/domain/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@
4444
BacktestDateRange, Backtest, BacktestMetrics, combine_backtests, \
4545
BacktestPermutationTest, BacktestEvaluationFocus, \
4646
generate_backtest_summary_metrics, load_backtests_from_directory, \
47-
save_backtests_to_directory, retag_backtests
47+
save_backtests_to_directory, retag_backtests, migrate_backtests, \
48+
BacktestIndex, save_bundle, open_bundle, BUNDLE_EXT, \
49+
BUNDLE_FORMAT_VERSION
50+
from .backtesting.backtest_utils import resolve_backtest_path
4851
from .algorithm_id import generate_algorithm_id
4952
from .pipeline import (
5053
Pipeline,
@@ -173,6 +176,13 @@
173176
"load_backtests_from_directory",
174177
"save_backtests_to_directory",
175178
"retag_backtests",
179+
"migrate_backtests",
180+
"resolve_backtest_path",
181+
"BacktestIndex",
182+
"save_bundle",
183+
"open_bundle",
184+
"BUNDLE_EXT",
185+
"BUNDLE_FORMAT_VERSION",
176186
"generate_algorithm_id",
177187
# Pipeline API (Phase 1, see docs/design/pipeline-api.md)
178188
"Pipeline",

investing_algorithm_framework/domain/backtesting/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,19 @@
77
from .backtest_evaluation_focuss import BacktestEvaluationFocus
88
from .combine_backtests import combine_backtests, \
99
generate_backtest_summary_metrics
10-
from .backtest_utils import load_backtests_from_directory, \
11-
save_backtests_to_directory, retag_backtests
10+
from .backtest_utils import (
11+
load_backtests_from_directory,
12+
save_backtests_to_directory,
13+
retag_backtests,
14+
migrate_backtests,
15+
BacktestIndex,
16+
)
17+
from .bundle import (
18+
save_bundle,
19+
open_bundle,
20+
BUNDLE_EXT,
21+
BUNDLE_FORMAT_VERSION,
22+
)
1223

1324
__all__ = [
1425
"Backtest",
@@ -18,9 +29,15 @@
1829
"BacktestRun",
1930
"BacktestPermutationTest",
2031
"BacktestEvaluationFocus",
32+
"BacktestIndex",
2133
"combine_backtests",
2234
"generate_backtest_summary_metrics",
2335
"load_backtests_from_directory",
2436
"save_backtests_to_directory",
2537
"retag_backtests",
38+
"migrate_backtests",
39+
"save_bundle",
40+
"open_bundle",
41+
"BUNDLE_EXT",
42+
"BUNDLE_FORMAT_VERSION",
2643
]

0 commit comments

Comments
 (0)