The .iafbt Bundle File

July 20, 2026 · View on GitHub

This document describes how a backtest is persisted on disk as an .iafbt ("Investing Algorithm Framework BackTest") bundle. It is the working reference for developers building on, inspecting, or interoperating with the format — including third-party tooling (the Finterion upload CLI, ingestion pipelines, debug_iafbt.py).

Reference implementation: investing_algorithm_framework/domain/backtesting/bundle.py.


1. What an .iafbt file is

An .iafbt file is a single, self-contained binary container that holds the full results of one algorithm's backtest, across one or more studies (signal variants) and one or more rolling windows per study.

It stores:

  • All run dicts — vector_runs / event_runs — inside each study
  • Summary metrics (cross-window scalar roll-ups) per engine, per study
  • Per-universe summary caches (summaries_by_universe)
  • Per-run heavy time series: equity curve, drawdown, monthly/yearly returns, rolling Sharpe, TWR variants — extracted as Parquet blobs
  • Trades, orders, positions, portfolio snapshots (msgpack inline)
  • Signals, recorded values, data-source descriptors
  • Algorithm ID, strategy IDs, parameters, tag, metadata
  • Study identity: name, description, universe, backtest windows
  • Optional Monte-Carlo test results
  • Optional universe catalogue (multi-universe bundles)

The format is:

  • Versioned — a format_version integer is encoded in the header.
  • Compressed — zstd level 19 over a MessagePack document.
  • Language-portable — anything that reads MessagePack + zstd + Parquet can decode it. No Python pickle anywhere.
  • Atomic on writesave_bundle() writes to <path>.tmp and os.replaces it into place.

2. On-disk layout

2.1 Outer envelope

Every .iafbt file starts with a fixed 8-byte header:

+-----------+-----------+--------------------------------+
| 4 bytes   | 4 bytes   |  N bytes                       |
| "IAFB"    | uint32 LE | zstd(level=19, msgpack(doc))   |
+-----------+-----------+--------------------------------+
  magic       version     compressed body
  • Magic (b"IAFB"): identifies the format. Readers MUST verify this before doing any work.
  • Version (uint32 little-endian): currently 5. Readers MUST reject any value greater than the highest version they understand.
  • Body: a zstd-compressed MessagePack document.
ConstantValueDefined in
_MAGICb"IAFB"bundle.py
BUNDLE_EXT".iafbt"bundle.py
BUNDLE_FORMAT_VERSION5bundle.py
_ZSTD_LEVEL19bundle.py

2.2 The body document (v5 — current)

After decompression and msgpack unpacking, the body is exactly the output of Backtest.to_dict() plus a format_version key and an optional blobs map. The canonical top-level shape is:

{
    "format_version": 5,

    # Algorithm identity
    "algorithm_id": str,
    "anchor_algorithm_id": str | None,
    "tag": str | None,
    "strategy_ids": list[str],
    "parameters": dict,
    "metadata": dict,

    # Studies — one entry per strategy variant / signal.
    # Each value is a Study.to_dict() dict (see §2.3).
    "studies": {
        "<study_name>": { ... },
    },

    # Optional: universe catalogue for multi-universe bundles.
    # Present only when the Backtest was constructed with universes=[...].
    # Each entry is a Universe.to_dict() dict.
    "universes": [ { "key": str, "symbols": [...], ... }, ... ],

    # Optional: embedded Parquet blobs for heavy metric series.
    # Keys follow the per-study namespacing (see §3.1).
    "blobs": {
        "studies/<study_name>/<engine>_runs/<idx>/metrics/<field>.parquet": bytes,
    },
}

There are no top-level engine_type, vector_runs, event_runs, backtest_runs, study_name, study_description, or risk_free_rate keys. All per-study and per-engine data lives inside studies.

2.3 The study dict

Each entry in studies is the output of Study.to_dict():

{
    "name": str,                      # e.g. "in_sample_param_sweep"
    "description": str | None,

    # Single universe for this study. Universe.to_dict() shape:
    #   key, symbols, trading_symbol, market, initial_capital,
    #   risk_free_rate, metadata
    "universe": { ... } | None,

    # BacktestWindow list — one entry per rolling window / fold.
    # Each entry: {name, train_range, test_range, warmup_days, fold_index}
    # where train_range / test_range are {name, start, end} (ISO-8601).
    # gap_days is derived on read as (test_range.start - train_range.end).days.
    "backtest_windows": [ { ... }, ... ],

    "metadata": dict,

    # Monte-Carlo test results for this study.
    "monte_carlo_tests": [ { ... }, ... ],

    # Per-engine run lists and roll-up summaries.
    # These keys always appear; absent engines have empty lists / null.
    "vector_runs":                    [ <run_dict>, ... ],
    "vector_summary":                 <summary_dict> | None,
    "vector_summaries_by_universe":   { "<universe_key>": <summary_dict>, ... },

    "event_runs":                     [ <run_dict>, ... ],
    "event_summary":                  <summary_dict> | None,
    "event_summaries_by_universe":    { "<universe_key>": <summary_dict>, ... },
}

2.4 Legacy format versions (read-only)

VersionShapeNotes
v1{"format_version": 1, "backtest": <Backtest.to_dict()>, "ohlcv": ...}Heavy series inline as [(float, iso)] lists. No Parquet blobs.
v2–v4Top-level engine_type, vector_runs/event_runs, flat study_name/universes keysReader routes through _envelope_to_backtest_dict to normalise.

All historical versions remain readable indefinitely. New code never writes v1–v4.


3. Heavy time series — Parquet blob extraction

3.1 Blob keying

Each heavy series is extracted out of the run's backtest_metrics dict and stored as embedded Parquet bytes under the key:

studies/<study_name>/<engine>_runs/<index>/metrics/<field_name>.parquet

where <engine> is vector or event, <index> is the zero-based offset of the run within that engine's run list, and <field_name> is one of the eight series listed below.

The original field in backtest_metrics is replaced with a blob reference:

{"@blob": "studies/in_sample_param_sweep/vector_runs/0/metrics/equity_curve.parquet"}

On read, blob references are resolved back to [(value, iso_string), ...] so consumers always see the same shape regardless of storage format.

3.2 Which eight fields are extracted

All eight share the shape List[[float_value, iso_datetime]] when inline:

FieldNotes
equity_curvePortfolio value over time.
drawdown_seriesDrawdown vs running peak (decimal, ≤ 0).
cumulative_return_seriesCumulative return vs initial_unallocated.
rolling_sharpe_ratioRolling Sharpe; window size is engine-defined.
monthly_returnsOne point per month; timestamp = end-of-month UTC.
yearly_returnsOne point per year; timestamp = year-boundary date.
twr_equity_curveTime-weighted-return variant — cash flows scrubbed.
twr_drawdown_seriesTWR variant of drawdown_series.

3.3 Blob payload format

Each blob is a two-column Parquet table (zstd level 5):

ColumnTypeSemantics
tsint64UTC epoch milliseconds.
valuefloat64The metric value at that timestamp.

3.4 Defensive encoding rules

  • If a series has fewer than 2 usable entries it is left inline (Parquet per-file overhead exceeds the savings for tiny series).
  • Readers MUST handle both blob references and inline lists for every one of the eight fields.
  • summary_only=True skips Parquet decode; blob fields come back as [].

4. OHLCV side store (optional)

When save_bundle(..., include_ohlcv=True), OHLCV DataFrames are written to a sibling content-addressed directory (<bundle_parent>/ohlcv/) and the bundle embeds a manifest:

"ohlcv": {
    "store_dir": "ohlcv",   # relative to the bundle file
    "manifest": {
        "BTC/USDT__1h": "a3f1c4...e9.parquet",
    },
}

On read, backtest.ohlcv exposes a LazyOhlcvDict — Parquet blobs are decoded only when their key is accessed. See ohlcv-dedup-protocol.md for cross-bundle deduplication semantics.


5. Read and write APIs

5.1 Writer

save_bundle(backtest, path, *, include_ohlcv=False, summary_only=False) -> Path
  1. Calls backtest.to_dict() to get the canonical dict.
  2. Adds "format_version": BUNDLE_FORMAT_VERSION.
  3. Extracts the eight heavy series into Parquet blobs per study/engine/run.
  4. Writes atomically (<target>.tmpos.replace).

If path is a directory, the filename resolves to <algorithm_id>.iafbt.

5.2 Reader

open_bundle(path, *, summary_only=False) -> Backtest
  1. Reads 8 bytes; verifies magic; parses version.
  2. Decompresses (zstd) and unpacks (msgpack).
  3. For v5: passes the doc directly to Backtest.from_dict().
  4. For v1–v4: normalises through _envelope_to_backtest_dict() first.
  5. Resolves every {"@blob": ...} reference (skipped when summary_only=True).

summary_only=True is the fast path for ranking pipelines — all scalar summary metrics are populated; the eight heavy series come back as [].

5.3 Detection

is_bundle_file(path)   # True iff first 4 bytes == b"IAFB"
peek_bundle_format_version(path)  # int, no full decode needed

6. Quick reference — public surface

from investing_algorithm_framework import (
    BUNDLE_FORMAT_VERSION,   # int, currently 5
    Backtest,
)
from investing_algorithm_framework.domain.backtesting.bundle import (
    BUNDLE_EXT,              # ".iafbt"
    save_bundle,
    open_bundle,             # supports summary_only=True
    is_bundle_file,
    peek_bundle_format_version,
)

7. Compatibility & versioning policy

  • The framework reads all historical versions indefinitely.
  • Writers always emit BUNDLE_FORMAT_VERSION (currently 5).
  • The 4-byte IAFB magic and uint32 LE version field are guaranteed stable across all future versions.
  • Additive changes within v5 (new keys inside a study dict) MUST be safe for existing v5 readers — unknown keys are ignored on read.

8. The run dict — full reference

A study's vector_runs / event_runs list contains one run dict per backtest window. Each is the output of BacktestRun.to_dict() and is reconstructed by BacktestRun.from_dict() on read.

8.1 Top-level run fields

Window identity & sizing

FieldTypeNotes
backtest_start_dateISO-8601 string (UTC)Inclusive start of this window.
backtest_end_dateISO-8601 string (UTC)Inclusive end of this window.
backtest_date_range_namestring | nullHuman label (e.g. "w1-train").
created_atISO-8601 string (UTC)When this run was produced.
trading_symbolstringQuote currency (e.g. "EUR").
initial_unallocatedfloatStarting cash for this window.
number_of_daysint(end - start).days.
symbolslist[string]All instruments traded during the window.

Counters (denormalised for fast listing)

FieldTypeNotes
number_of_tradesintTotal trades touched.
number_of_trades_closedintClosed inside the window.
number_of_trades_openintStill open at end.
number_of_ordersintOrders placed.
number_of_positionsintDistinct positions held.

Free-form attachments

FieldTypeNotes
metadatadict[str, str]Per-run free-form labels. The framework never inspects keys.
data_sourceslist[dict]Descriptors for data consumed (provider, symbol, timeframe, start/end).

8.2 backtest_metrics — per-run metrics dict

Serialised BacktestMetrics. Three layers:

  1. Window context — repeats identity fields so the metrics object is self-contained.
  2. Scalar metrics — every numeric performance / risk statistic. Always inline; populated by summary_only=True.
  3. Heavy time series — the eight fields extracted into Parquet blobs (see §3). Come back as [(value, iso_string), ...] on read.

Key scalar fields:

FieldNotes
total_net_gain / _percentageNet P&L for this window.
cagrCompound annual growth rate.
sharpe_ratioAnnualised; uses study.universe.risk_free_rate.
sortino_ratio / calmar_ratio / profit_factorStandard risk-adjusted metrics.
max_drawdown / max_drawdown_durationDecimal (≤ 0) and days.
number_of_trades_closedClosed-trade count.
win_rateDecimal share of winning closed trades.
exposure_ratioAvg fraction of capital deployed.

8.3 Trades, orders, positions, portfolio snapshots

Stored as msgpack lists of dicts on the run dict (not inside backtest_metrics).

FieldElement type
tradesTrade.to_dict()
ordersOrder.to_dict()
positionsPosition.to_dict()
portfolio_snapshotsPortfolioSnapshot.to_dict() — typically the largest collection.

8.4 Signals, signal events, recorded values

FieldNotes
signals{symbol: {"buy": [iso_ts, ...], "sell": [iso_ts, ...]}} — only True ticks kept.
signal_eventsAudit log: [{date, symbol, signal, executed, reason}, ...].
recorded_valuesStrategy-defined per-tick diagnostics: {name: [{datetime, value}, ...]}.

9. The summary dict — full reference

Each study's engine slot carries a vector_summary / event_summary — the serialised BacktestSummaryMetrics — representing an aggregate across all runs in that slot.

The summary is the source of truth for the Tier-1 SQLite index. Every scalar is promoted to its own column so rank_index() can sort 10k+ bundles without opening any .iafbt.

Key fields mirror the per-run metrics but aggregate across windows:

FieldNotes
total_net_gain / _percentageSum across all windows.
average_net_gain / _percentageTime-weighted mean across windows.
sharpe_ratio / sortino_ratio / calmar_ratioPooled across runs.
max_drawdownWorst drawdown across all windows.
number_of_windowsTotal window count.
number_of_profitable_windowsWindows with positive net gain.
number_of_windows_with_tradesWindows with ≥ 1 closed trade.
return_consistency / win_rate_consistency / sharpe_consistencyCross-window stability scores (0–1).
consistency_scoreComposite of the three stability scores.