Skip to content

Commit 13d8a58

Browse files
committed
feat(store): BacktestStore Protocol + LocalDirStore (epic #540 phase 3a)
Introduces the storage seam that decouples *where* a backtest is persisted from the rest of the framework. Phase 3a is intentionally scoped to the Protocol + a thin adapter over today's .iafbt layout; LocalTieredStore (Tier-2 Parquet + Tier-3 chunks) and FinterionStore land in follow-up PRs. - BacktestStore Protocol: write / open / exists / delete / iter_handles / iter_index_rows / __len__ / __contains__. Mirrors today's Backtest.save_bundle / Backtest.open semantics so LocalDirStore is a 1:1 adapter. - StoreHandle: opaque str token (relative bundle path for LocalDirStore; uuid7 run_id for the upcoming tiered stores). - StoreError + StoreHandleNotFoundError. - Optional capability mixin SupportsCopyFrom — declared as a separate runtime_checkable Protocol so 'iaf migrate-store' (Phase 3d) and 'finterion push' (closed-source) can isinstance-test for it. Future capabilities (SupportsRelations for the strategy/version/report graph, SupportsContentAddressedChunks for Tier-3 dedup) follow the same pattern. - LocalDirStore: handle = bundle path relative to the root, so the store stays portable across moves. Sidecar SqliteBacktestIndex (built lazily, incrementally — same machinery as 'iaf index') backs iter_index_rows so listing does not re-decode bundles. Path-traversal guards reject handles that escape the root. - Tests (19, all passing): Protocol/SupportsCopyFrom conformance, round-trip, summary_only, default-handle derivation, sidecar index caching, copy_from with and without a handle subset, handle normalisation, path-traversal rejection, missing-handle error, delete idempotency. Targeted suite (store + index + cli): 86 / 86 passing.
1 parent 65a5025 commit 13d8a58

5 files changed

Lines changed: 643 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Storage abstraction for backtests (epic #540 phase 3).
2+
3+
A :class:`BacktestStore` is the single seam between the framework and
4+
*where* backtest results actually live. Phase 3a ships the Protocol
5+
and a thin :class:`LocalDirStore` adapter over the existing ``.iafbt``
6+
layout, so every consumer (HTML report, ``iaf list/rank``, the MCP
7+
server, future ``FinterionStore``) can be written against one
8+
interface.
9+
10+
See ``docs/design/tiered-backtest-storage.md`` §7 and the Phase 3
11+
plan in epic #540 for the full architecture.
12+
"""
13+
14+
from .base import (
15+
BacktestStore,
16+
StoreHandle,
17+
StoreError,
18+
StoreHandleNotFoundError,
19+
SupportsCopyFrom,
20+
)
21+
from .local_dir_store import LocalDirStore
22+
23+
__all__ = [
24+
"BacktestStore",
25+
"StoreHandle",
26+
"StoreError",
27+
"StoreHandleNotFoundError",
28+
"SupportsCopyFrom",
29+
"LocalDirStore",
30+
]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""``BacktestStore`` Protocol + capability mixins (epic #540 phase 3a).
2+
3+
A :class:`BacktestStore` decouples *where* a backtest is persisted
4+
from the rest of the framework. Three concrete implementations are
5+
planned:
6+
7+
* :class:`LocalDirStore` (this PR) — directory of ``.iafbt`` bundles.
8+
Adapter over today's :meth:`Backtest.save_bundle` /
9+
:meth:`Backtest.open` so existing layouts keep working unchanged.
10+
* ``LocalTieredStore`` (Phase 3b/3c) — SQLite Tier-1 + per-project
11+
Parquet datasets (Tier-2) + content-addressed chunks (Tier-3).
12+
* ``FinterionStore`` (closed-source) — HTTP adapter over Finterion's
13+
hosted tiered backend, with the optional :class:`SupportsRelations`
14+
capability for strategy-version / report linkage.
15+
16+
The Protocol stays deliberately small. Capabilities that not every
17+
store can or should implement (efficient bulk migration, relational
18+
graph queries, …) are declared as separate Protocols so callers can
19+
``isinstance(store, SupportsCopyFrom)``-test for them at runtime.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import (
25+
Iterable,
26+
Iterator,
27+
Optional,
28+
Protocol,
29+
runtime_checkable,
30+
)
31+
32+
from investing_algorithm_framework.domain import (
33+
Backtest,
34+
BacktestIndexRow,
35+
)
36+
37+
38+
# A handle is an opaque, store-scoped, stable string identifier for a
39+
# single backtest record. For ``LocalDirStore`` it is the bundle path
40+
# relative to the store root; for ``LocalTieredStore`` it will be the
41+
# ``run_id`` (uuid7); for ``FinterionStore`` a remote URI. Callers
42+
# should treat handles as opaque tokens — never parse them.
43+
StoreHandle = str
44+
45+
46+
class StoreError(Exception):
47+
"""Base class for all :class:`BacktestStore` errors."""
48+
49+
50+
class StoreHandleNotFoundError(StoreError, KeyError):
51+
"""Raised when an operation references a handle that does not exist."""
52+
53+
54+
@runtime_checkable
55+
class BacktestStore(Protocol):
56+
"""Minimal write/read/list/delete contract for backtest storage.
57+
58+
All implementations must be safe for concurrent reads. Concurrent
59+
writes are implementation-specific (``LocalDirStore`` allows
60+
them; tiered stores will document their guarantees).
61+
62+
The contract intentionally mirrors today's
63+
:meth:`Backtest.save_bundle` / :meth:`Backtest.open` semantics so
64+
:class:`LocalDirStore` is a 1:1 adapter and existing tests keep
65+
passing without behavioural drift.
66+
"""
67+
68+
def write(
69+
self,
70+
backtest: Backtest,
71+
*,
72+
handle: Optional[StoreHandle] = None,
73+
) -> StoreHandle:
74+
"""Persist *backtest* and return its handle.
75+
76+
If *handle* is supplied the store should write to (or replace)
77+
that exact location; otherwise the store picks a deterministic
78+
handle from the backtest's identity (e.g. ``algorithm_id`` for
79+
local stores, ``run_id`` for tiered stores).
80+
"""
81+
...
82+
83+
def open(
84+
self,
85+
handle: StoreHandle,
86+
*,
87+
summary_only: bool = False,
88+
) -> Backtest:
89+
"""Materialise the backtest at *handle*.
90+
91+
``summary_only`` mirrors :meth:`Backtest.open`: when True the
92+
store should avoid decoding heavy time-series payloads (the
93+
Tier-2 Parquet bodies, in tiered terminology).
94+
"""
95+
...
96+
97+
def exists(self, handle: StoreHandle) -> bool:
98+
"""Return True if *handle* refers to a stored backtest."""
99+
...
100+
101+
def delete(self, handle: StoreHandle) -> None:
102+
"""Remove the backtest at *handle*. No-op if absent."""
103+
...
104+
105+
def iter_handles(self) -> Iterator[StoreHandle]:
106+
"""Yield every handle currently in the store, in stable order."""
107+
...
108+
109+
def iter_index_rows(self) -> Iterator[BacktestIndexRow]:
110+
"""Yield a :class:`BacktestIndexRow` for every stored backtest.
111+
112+
Implementations that have a Tier-1 index should serve this
113+
from the index (no bulk decode); :class:`LocalDirStore`
114+
falls back to ``Backtest.open(..., summary_only=True)`` per
115+
bundle when no sidecar index is present.
116+
"""
117+
...
118+
119+
def __len__(self) -> int:
120+
"""Number of backtests currently in the store."""
121+
...
122+
123+
124+
# ---------------------------------------------------------------------------
125+
# Optional capabilities — declared as separate Protocols so callers can
126+
# feature-test with isinstance(store, SupportsXxx).
127+
# ---------------------------------------------------------------------------
128+
129+
130+
@runtime_checkable
131+
class SupportsCopyFrom(Protocol):
132+
"""Stores that can ingest from another :class:`BacktestStore`.
133+
134+
Used by ``iaf migrate-store`` (Phase 3d) to move bundles between
135+
a :class:`LocalDirStore` and a ``LocalTieredStore`` (or to push
136+
to ``FinterionStore``). Implementations may optimise — e.g. a
137+
tiered store can dedup chunks during ingest — but the default
138+
fallback is a per-handle ``write(src.open(h))`` loop.
139+
"""
140+
141+
def copy_from(
142+
self,
143+
src: "BacktestStore",
144+
*,
145+
handles: Optional[Iterable[StoreHandle]] = None,
146+
) -> int:
147+
"""Copy backtests from *src* into this store.
148+
149+
Args:
150+
src: source store to read from.
151+
handles: optional subset of handles to copy. If None, all
152+
handles in *src* are copied.
153+
154+
Returns:
155+
Number of backtests successfully copied.
156+
"""
157+
...

0 commit comments

Comments
 (0)