Skip to content

Commit 0a6ff46

Browse files
committed
feat(backtest): BacktestIndexRow DTO + Backtest.index_row() (epic #540 phase 1)
Lift the existing untyped flat-row index helper into a public, typed Tier-1 contract: * New BacktestIndexRow dataclass (domain/backtesting/backtest_index_row.py) with identity / provenance / config / nested summary_metrics + forward-compat extras. Lossless to_flat_dict / from_flat_dict round trip for Parquet, SQL and JSON sinks. * New Backtest.index_row(bundle_path=None) method. Builds without decoding any v2 Parquet metric blobs, so it works against bundles loaded with Backtest.open(..., summary_only=True). This is the fast read path the upcoming 'iaf index' CLI (phase 2) and any tiered store implementation (phase 3) will rely on. * _backtest_to_index_row in backtest_utils now delegates to BacktestIndexRow.to_flat_dict() so the wire shape and the in-memory shape are a single source of truth (no behavioural change for the existing index.parquet sidecar). * Re-export BacktestIndexRow from the domain and top-level packages. * docs/design/tiered-backtest-storage.md \xa73.1 + roadmap row updated to reference the typed contract. Tests: 5 new (in-memory derivation, flat round-trip incl. NaN, unknown columns landing in extras, derivation from a summary_only=True bundle load). Full backtests suite green (29/29).
1 parent f4ba690 commit 0a6ff46

8 files changed

Lines changed: 377 additions & 24 deletions

File tree

docs/design/tiered-backtest-storage.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,20 @@ Cross-bundle redundancy is the dominant unexploited source of size:
6969
| Identity | `run_id` (uuid7), `bundle_id`, `parent_sweep_id`, `tenant_id`, `project_id` |
7070
| Provenance | `algorithm_id`, `code_hash`, `framework_version`, `created_at` |
7171
| Config | `engine_type`, `params_hash`, `symbols_hash`, `date_range_name`, `start_date`, `end_date`, `tag` |
72-
| Scalar metrics | `BacktestSummary` fields — Sharpe, Sortino, max_dd, CAGR, total_net_gain, win_rate, … |
72+
| Scalar metrics | `BacktestSummaryMetrics` fields (nested in `BacktestIndexRow.summary_metrics`) — Sharpe, Sortino, max_dd, CAGR, total_net_gain, win_rate, … |
7373
| Refs | `snapshots_dataset_uri`, `trades_dataset_uri`, `metric_series_dataset_uri`, `ohlcv_chunk_hashes[]`, `code_chunk_hash`, `symbols_chunk_hash`, `params_chunk_hash` |
7474

7575
Row size: ~1–2 KB. 12,500 rows ≈ 25 MB. Fits comfortably in SQLite for local users.
7676

77+
> **Status (epic #540 phase 1, v8.10):** the typed contract for this row
78+
> ships as `investing_algorithm_framework.BacktestIndexRow`, derived
79+
> via `Backtest.index_row(bundle_path=...)`. The method works against
80+
> bundles loaded with `Backtest.open(path, summary_only=True)` — no
81+
> Parquet metric blobs are decoded on the fast index path. The
82+
> existing `BacktestIndex` Parquet sidecar is now built on top of this
83+
> typed row (`BacktestIndexRow.to_flat_dict()`), making the wire
84+
> shape and the in-memory shape a single source of truth.
85+
7786
### 3.2 Tier 2 schemas (Parquet, long format)
7887

7988
`portfolio_snapshots/`:
@@ -215,7 +224,7 @@ Zero behavioural difference vs today. Single-file `.iafbt` users get `export()`
215224
| Phase | Change | Risk |
216225
|---|---|---|
217226
| **v8.9 (shipped)** | Bundle format v2; engine_type split; zstd 19; summary_only read ||
218-
| **v8.10** | `Backtest.scalar_summary()` (no decode of bulk); `iaf index <dir>` builds a SQLite index over a folder of bundles; `BacktestSummary` DTO with stable schema | Low — additive read paths |
227+
| **v8.10** | `Backtest.index_row()` (no decode of bulk); `iaf index <dir>` builds a SQLite index over a folder of bundles; `BacktestIndexRow` DTO with stable schema | Low — additive read paths |
219228
| **v8.11** | `BacktestStore` interface with `LocalDirStore` (today) and `LocalTieredStore`. `.iafbt` becomes export format; service constructors accept a store | Medium — touches every backtest service constructor; deprecation flag for one minor cycle |
220229
| **Finterion (closed)** | `RemoteTieredStore` over Postgres + S3 + chunk service | Closed-source, unblocked by v8.11 |
221230

investing_algorithm_framework/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Trade, APP_MODE, AppMode, DATETIME_FORMAT, load_backtests_from_directory, \
2020
iter_backtests_from_directory, \
2121
BacktestDateRange, convert_polars_to_pandas, BacktestRun, \
22+
BacktestIndexRow, \
2223
DEFAULT_LOGGING_CONFIG, DataType, DataProvider, StopLossRule, \
2324
ScalingRule, TradingCost, \
2425
TradeStatus, generate_backtest_summary_metrics, generate_algorithm_id, \
@@ -222,6 +223,7 @@
222223
"get_positive_trades",
223224
"get_number_of_trades",
224225
"BacktestRun",
226+
"BacktestIndexRow",
225227
"load_backtests_from_directory",
226228
"iter_backtests_from_directory",
227229
"save_backtests_to_directory",

investing_algorithm_framework/domain/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
csv_to_list, StoppableThread, load_csv_into_dict, tqdm, \
4444
is_timezone_aware, sync_timezones, get_timezone
4545
from .backtesting import BacktestRun, BacktestSummaryMetrics, \
46+
BacktestIndexRow, \
4647
BacktestDateRange, Backtest, BacktestMetrics, combine_backtests, \
4748
BacktestPermutationTest, BacktestEvaluationFocus, \
4849
generate_backtest_summary_metrics, load_backtests_from_directory, \

investing_algorithm_framework/domain/backtesting/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .backtest_summary_metrics import BacktestSummaryMetrics
2+
from .backtest_index_row import BacktestIndexRow
23
from .backtest_date_range import BacktestDateRange
34
from .backtest_metrics import BacktestMetrics
45
from .backtest_run import BacktestRun
@@ -25,6 +26,7 @@
2526
__all__ = [
2627
"Backtest",
2728
"BacktestSummaryMetrics",
29+
"BacktestIndexRow",
2830
"BacktestDateRange",
2931
"BacktestMetrics",
3032
"BacktestRun",

investing_algorithm_framework/domain/backtesting/backtest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .backtest_permutation_test import BacktestPermutationTest
1414
from .backtest_date_range import BacktestDateRange
1515
from .backtest_summary_metrics import BacktestSummaryMetrics
16+
from .backtest_index_row import BacktestIndexRow
1617
from .combine_backtests import generate_backtest_summary_metrics
1718

1819

@@ -215,6 +216,42 @@ def get_backtest_metrics(
215216
return run.backtest_metrics
216217
return None
217218

219+
def index_row(
220+
self, bundle_path: Union[str, None] = None,
221+
) -> BacktestIndexRow:
222+
"""Return the typed Tier-1 row contract for this backtest.
223+
224+
The row carries identity, provenance, config and the scalar
225+
:class:`BacktestSummaryMetrics`, but **no heavy time-series
226+
data**. It can therefore be built without decoding any v2
227+
Parquet metric blobs (``Backtest.open(path,
228+
summary_only=True)`` is the canonical fast read path).
229+
230+
Args:
231+
bundle_path: Optional location the bundle was loaded from
232+
(relative or absolute). Stored verbatim in
233+
:pyattr:`BacktestIndexRow.bundle_path` for downstream
234+
indexers that need to round-trip back to the file.
235+
236+
Returns:
237+
BacktestIndexRow: typed, flat-friendly row.
238+
239+
See also:
240+
``docs/design/tiered-backtest-storage.md`` §3.1 — the
241+
authoritative schema this row implements.
242+
"""
243+
return BacktestIndexRow(
244+
algorithm_id=self.algorithm_id,
245+
tag=self.tag,
246+
bundle_path=bundle_path,
247+
engine_type=self.engine_type,
248+
risk_free_rate=self.risk_free_rate,
249+
parameters=dict(self.parameters or {}),
250+
strategy_ids=list(self.strategy_ids or []),
251+
number_of_runs=len(self.backtest_runs or []),
252+
summary_metrics=self.backtest_summary,
253+
)
254+
218255
def get_backtest_summary(self) -> Union[BacktestSummaryMetrics, None]:
219256
"""
220257
Retrieve the cross-window BacktestSummaryMetrics roll-up for
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Typed Tier-1 row contract for the tiered backtest store (epic #540).
2+
3+
A :class:`BacktestIndexRow` is the authoritative *flat, scalar-only*
4+
view of a backtest. It is what gets stored as a single row in:
5+
6+
* the :class:`BacktestIndex` Parquet sidecar produced by
7+
:func:`save_backtests_to_directory`;
8+
* the SQLite index built by ``iaf index`` (epic #540 phase 2);
9+
* the Tier-1 SQL table in any tiered store implementation
10+
(``LocalTieredStore`` and the closed-source remote stores).
11+
12+
The schema is **deliberately frozen** — adding a new column is an
13+
explicit decision and a doc update. Callers can always stash
14+
non-canonical fields in :pyattr:`extras` (a JSON-friendly dict) which
15+
is round-tripped opaquely.
16+
17+
This row is built without decoding any heavy time-series payloads;
18+
it is safe to materialise from a bundle opened with
19+
``Backtest.open(path, summary_only=True)``.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import json
25+
from dataclasses import dataclass, field, fields
26+
from typing import Any, Dict, List, Optional
27+
28+
from .backtest_summary_metrics import BacktestSummaryMetrics
29+
30+
31+
# Prefix used when flattening the nested summary metrics into a
32+
# single-level dict (e.g. for Parquet / SQL columns). Kept as a
33+
# module-level constant so consumers can reuse it without hard-coding
34+
# the string in two places.
35+
SUMMARY_FIELD_PREFIX = "summary."
36+
37+
38+
@dataclass
39+
class BacktestIndexRow:
40+
"""One row of the backtest index — the Tier-1 contract.
41+
42+
Field groups follow the design doc
43+
(``docs/design/tiered-backtest-storage.md`` §3.1):
44+
45+
* **Identity** — ``algorithm_id``, ``tag``, ``bundle_path``
46+
* **Provenance** — ``framework_version``, ``engine_type``,
47+
``risk_free_rate``
48+
* **Config** — ``parameters``, ``strategy_ids``, ``number_of_runs``
49+
* **Scalar metrics** — :pyattr:`summary_metrics`, the existing
50+
:class:`BacktestSummaryMetrics` dataclass
51+
* **Forward-compat** — :pyattr:`extras`, a free-form dict the
52+
bundle reader populates for non-canonical scalar fields
53+
54+
Notes:
55+
The schema is intentionally flat for the wire shapes that need
56+
flatness (Parquet, SQL). For ergonomic Python use, prefer
57+
accessing :pyattr:`summary_metrics` directly.
58+
"""
59+
60+
# -- Identity --------------------------------------------------------
61+
algorithm_id: Optional[str] = None
62+
tag: Optional[str] = None
63+
bundle_path: Optional[str] = None
64+
65+
# -- Provenance ------------------------------------------------------
66+
framework_version: Optional[str] = None
67+
engine_type: Optional[str] = None
68+
risk_free_rate: Optional[float] = None
69+
70+
# -- Config ----------------------------------------------------------
71+
parameters: Dict[str, Any] = field(default_factory=dict)
72+
strategy_ids: List[Any] = field(default_factory=list)
73+
number_of_runs: int = 0
74+
75+
# -- Scalar metrics --------------------------------------------------
76+
summary_metrics: Optional[BacktestSummaryMetrics] = None
77+
78+
# -- Forward-compat --------------------------------------------------
79+
extras: Dict[str, Any] = field(default_factory=dict)
80+
81+
# ------------------------------------------------------------------
82+
# Flat-dict round-trip (Parquet / SQL / JSON wire shape)
83+
# ------------------------------------------------------------------
84+
def to_flat_dict(self) -> Dict[str, Any]:
85+
"""Flatten into a single-level dict.
86+
87+
Summary-metric scalars are emitted under
88+
:data:`SUMMARY_FIELD_PREFIX` keys (``summary.sharpe_ratio``
89+
etc.). Complex fields (``parameters``, ``strategy_ids``) are
90+
JSON-encoded so the result fits any tabular sink.
91+
"""
92+
out: Dict[str, Any] = {
93+
"algorithm_id": self.algorithm_id,
94+
"tag": self.tag,
95+
"bundle_path": self.bundle_path,
96+
"framework_version": self.framework_version,
97+
"engine_type": self.engine_type,
98+
"risk_free_rate": self.risk_free_rate,
99+
"number_of_runs": self.number_of_runs,
100+
}
101+
102+
# parameters / strategy_ids → JSON for tabular round-trip
103+
out["parameters"] = (
104+
_safe_json(self.parameters) if self.parameters else None
105+
)
106+
out["strategy_ids"] = (
107+
_safe_json(self.strategy_ids) if self.strategy_ids else None
108+
)
109+
110+
# Scalar summary metrics, prefixed
111+
if self.summary_metrics is not None:
112+
for k, v in self.summary_metrics.to_dict().items():
113+
if isinstance(v, (int, float, str, bool)) or v is None:
114+
out[f"{SUMMARY_FIELD_PREFIX}{k}"] = v
115+
116+
# Forward-compat extras, prefixed to avoid colliding with the
117+
# canonical column set.
118+
for k, v in (self.extras or {}).items():
119+
if isinstance(v, (int, float, str, bool)) or v is None:
120+
out[f"extras.{k}"] = v
121+
122+
return out
123+
124+
@classmethod
125+
def from_flat_dict(cls, row: Dict[str, Any]) -> "BacktestIndexRow":
126+
"""Reconstruct a row from the flat dict shape produced by
127+
:meth:`to_flat_dict`. Unknown keys land in :pyattr:`extras`."""
128+
canonical = {f.name for f in fields(cls)} - {
129+
"summary_metrics", "extras"
130+
}
131+
132+
kwargs: Dict[str, Any] = {}
133+
summary_dict: Dict[str, Any] = {}
134+
extras: Dict[str, Any] = {}
135+
136+
for k, v in row.items():
137+
if k in canonical:
138+
if k in ("parameters", "strategy_ids"):
139+
if v is None:
140+
kwargs[k] = {} if k == "parameters" else []
141+
continue
142+
if isinstance(v, str):
143+
try:
144+
kwargs[k] = json.loads(v)
145+
continue
146+
except (TypeError, ValueError):
147+
pass
148+
kwargs[k] = v
149+
elif k.startswith(SUMMARY_FIELD_PREFIX):
150+
summary_dict[k[len(SUMMARY_FIELD_PREFIX):]] = v
151+
elif k.startswith("extras."):
152+
extras[k[len("extras."):]] = v
153+
else:
154+
# Unknown key — preserve under extras (round-trip safety).
155+
extras[k] = v
156+
157+
kwargs.setdefault("parameters", {})
158+
kwargs.setdefault("strategy_ids", [])
159+
160+
return cls(
161+
**kwargs,
162+
summary_metrics=(
163+
BacktestSummaryMetrics.from_dict(summary_dict)
164+
if summary_dict else None
165+
),
166+
extras=extras,
167+
)
168+
169+
170+
def _safe_json(obj: Any) -> Optional[str]:
171+
try:
172+
return json.dumps(obj, default=str)
173+
except (TypeError, ValueError):
174+
return None

investing_algorithm_framework/domain/backtesting/backtest_utils.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -551,28 +551,14 @@ def iter_backtests_from_directory(
551551

552552

553553
def _backtest_to_index_row(bt: Backtest, bundle_path: Optional[str] = None):
554-
"""Flatten a backtest's summary + identity into a single row."""
555-
summary = (
556-
bt.backtest_summary.to_dict() if bt.backtest_summary else {}
557-
)
558-
row = {
559-
"algorithm_id": getattr(bt, "algorithm_id", None),
560-
"tag": getattr(bt, "tag", None),
561-
"risk_free_rate": getattr(bt, "risk_free_rate", None),
562-
"bundle_path": bundle_path,
563-
"number_of_runs": len(bt.backtest_runs or []),
564-
}
565-
# Include scalar summary metrics only (no nested structures).
566-
for k, v in summary.items():
567-
if isinstance(v, (int, float, str, bool)) or v is None:
568-
row[f"summary.{k}"] = v
569-
# Parameters as JSON for round-trippability without exploding columns.
570-
if getattr(bt, "parameters", None):
571-
try:
572-
row["parameters"] = json.dumps(bt.parameters, default=str)
573-
except (TypeError, ValueError):
574-
row["parameters"] = None
575-
return row
554+
"""Flatten a backtest's summary + identity into a single row.
555+
556+
Thin wrapper around :meth:`Backtest.index_row` for callers that
557+
want the legacy flat dict shape (Parquet / SQL columns). The
558+
typed :class:`BacktestIndexRow` is the authoritative contract \u2014
559+
see ``docs/design/tiered-backtest-storage.md`` \u00a73.1.
560+
"""
561+
return bt.index_row(bundle_path=bundle_path).to_flat_dict()
576562

577563

578564
def _write_index(directory_path: Union[str, Path], backtests: List[Backtest]):

0 commit comments

Comments
 (0)