|
| 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