quantred 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
quantred/store/bars.py ADDED
@@ -0,0 +1,660 @@
1
+ """The bar cache: a persistent OHLCV cache behind ``MarketDataProvider``.
2
+
3
+ Two pieces, mirroring the derived/rebuildable-index philosophy ``quantred/store/trials.py``
4
+ already established for the research journal:
5
+
6
+ - :class:`BarCoverage` — a small SQLite index of *which* ``(symbol, timeframe)``
7
+ windows have actually been fetched from upstream. Derived, safe to delete: it can
8
+ be rebuilt (approximately) from the Parquet store it indexes.
9
+ - :class:`CachedMarketData` — a :class:`~quantred.marketdata.base.MarketDataProvider` that
10
+ wraps an upstream provider with :class:`~quantred.data.store.ParquetBarStore`
11
+ (the columnar substrate) and a :class:`BarCoverage`, so ``get_bars`` becomes
12
+ *check cache, fetch only the missing sub-ranges, write back, return*. A run can pin
13
+ to cache-only (``offline=True``) for a byte-reproducible, network-free result.
14
+
15
+ Bars are never assumed present just because a date range was requested before -
16
+ weekends/holidays genuinely have no bars, so "covered" is tracked independently of
17
+ "has rows": every fetch, even an empty one, is recorded, or a market-closed gap would
18
+ be re-queried forever.
19
+ """
20
+
21
+ import logging
22
+ import sqlite3
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
26
+
27
+ import pandas as pd
28
+
29
+ from quantred.marketdata.base import BarHandler, MarketDataProvider
30
+ from quantred.marketdata.client import TimeframeLike
31
+ from quantred.marketdata.timeframe import Timeframe
32
+ from quantred.settings import invocation, state_root
33
+
34
+ if TYPE_CHECKING: # pragma: no cover - typing only
35
+ from quantred.data.store import ParquetBarStore
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ #: Default cache location (gitignored, alongside logs/).
40
+ DEFAULT_CACHE_ROOT = state_root() / "cache" / "bars"
41
+ DEFAULT_COVERAGE_DB = state_root() / "cache" / "bars_coverage.db"
42
+
43
+ #: "Read everything" sentinels for the read-merge-write path: ParquetBarStore.scan()
44
+ #: takes an (as_of, lookback_days) window, not an arbitrary [start, end] range, so a
45
+ #: full-history read uses a lookback and an as_of far past anything real data can
46
+ #: reach - the same idiom tests/test_store.py already uses with lookback_days=10_000,
47
+ #: just wide enough to never truncate a merge.
48
+ _FAR_FUTURE = datetime(2100, 1, 1, tzinfo=timezone.utc)
49
+ _FULL_HISTORY_LOOKBACK_DAYS = 100_000
50
+
51
+ _SCHEMA = """
52
+ CREATE TABLE IF NOT EXISTS fetches (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ symbol TEXT NOT NULL,
55
+ timeframe TEXT NOT NULL,
56
+ start TEXT NOT NULL,
57
+ end TEXT NOT NULL,
58
+ adjustment TEXT NOT NULL,
59
+ fetched_at TEXT NOT NULL
60
+ );
61
+ CREATE INDEX IF NOT EXISTS idx_fetches_symbol ON fetches(symbol, timeframe);
62
+ """
63
+
64
+
65
+ #: Printed beside an offline cache miss that includes the gap the date-contract
66
+ #: change leaves. A bare date now means that market date in New York, so coverage
67
+ #: recorded when a naive date was read as UTC stops a few hours short of what the same
68
+ #: command asks for today. The window is genuinely uncached under the new reading - the
69
+ #: fix is a re-warm, not a tolerance - and saying so turns an unexplained miss on data
70
+ #: the user knows they have into provenance.
71
+ DATE_CONTRACT_NOTICE = (
72
+ "Note: a bare date now means that market date in America/New_York, not UTC midnight. "
73
+ "Coverage recorded under the older naive-date reading is a few hours short of the same "
74
+ "window today, so daily windows warmed before that change need one re-warm."
75
+ )
76
+
77
+
78
+ class CacheMiss(RuntimeError):
79
+ """Raised in ``--offline`` mode when a requested window isn't fully cached.
80
+
81
+ One miss covers every symbol of the request, and its warm command covers the whole
82
+ missing window. It used to stop at the first symbol and print a warm command with no
83
+ dates: running it fixed one name, the rerun failed on the next, and the warm's default
84
+ window need not include the request at all.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ missing: Dict[str, List[Tuple[pd.Timestamp, pd.Timestamp]]],
90
+ timeframe: str,
91
+ *,
92
+ date_shortfall: bool = False,
93
+ cache_dir: Optional[Path] = None,
94
+ why: Optional[str] = None,
95
+ ):
96
+ import shlex
97
+
98
+ self.missing = missing
99
+ self.timeframe = timeframe
100
+ self.date_shortfall = date_shortfall
101
+ self.cache_dir = cache_dir
102
+ symbols = list(missing)
103
+ first = min(s for gaps in missing.values() for s, _ in gaps)
104
+ last = max(e for gaps in missing.values() for _, e in gaps)
105
+ start_day, end_day = _warm_window(first, last)
106
+ shown = ", ".join(symbols[:8]) + (f" +{len(symbols) - 8} more" if len(symbols) > 8 else "")
107
+ warm = f"cache warm --symbols {','.join(symbols)} --timeframe {timeframe} --start {start_day} --end {end_day}"
108
+ # A run against its own cache directory has to warm that one; the default would
109
+ # fill a cache the rerun never reads, and it would miss again.
110
+ if cache_dir is not None and Path(cache_dir) != DEFAULT_CACHE_ROOT:
111
+ warm += f" --cache-dir {shlex.quote(str(cache_dir))}"
112
+ command = invocation(warm)
113
+ message = (
114
+ f"--offline: no cached {timeframe} bars for {shown} between {start_day} and {end_day}. "
115
+ f"Run `{command}` first."
116
+ )
117
+ if why:
118
+ message += f"\n{why}"
119
+ if date_shortfall:
120
+ message += f"\n{DATE_CONTRACT_NOTICE}"
121
+ super().__init__(message)
122
+
123
+
124
+ def _is_date_shortfall(gap: Tuple[pd.Timestamp, pd.Timestamp]) -> bool:
125
+ """Whether a gap is the sliver the date-contract change left, rather than real absence.
126
+
127
+ A bound recorded under the old naive-date reading stopped at UTC midnight, where the
128
+ same date now means New York midnight, so the gap runs exactly from one to the other
129
+ on one date. The note used to fire for any symbol with any coverage, so warming one
130
+ month and asking for another explained a re-warm that had nothing to do with dates.
131
+ """
132
+ from quantred.utils.timeutils import NEW_YORK
133
+
134
+ start, end = (_to_utc(t) for t in gap)
135
+ local_end = end.tz_convert(NEW_YORK)
136
+ return (
137
+ start == start.normalize()
138
+ and local_end == local_end.normalize()
139
+ and local_end.date() == start.date()
140
+ and start < end
141
+ )
142
+
143
+
144
+ def combine_misses(misses: List[CacheMiss]) -> List[CacheMiss]:
145
+ """One miss per timeframe covering every miss given, for a run that caught several.
146
+
147
+ A verdict runs its steps independently, so each one missed with its own window and
148
+ printed its own warm command; the reader had to work out that only the widest
149
+ sufficed. The combined miss names every symbol and the union of the windows.
150
+ """
151
+ by_timeframe: Dict[str, List[CacheMiss]] = {}
152
+ for miss in misses:
153
+ by_timeframe.setdefault(miss.timeframe, []).append(miss)
154
+ combined = []
155
+ for timeframe, group in by_timeframe.items():
156
+ missing: Dict[str, List[Tuple[pd.Timestamp, pd.Timestamp]]] = {}
157
+ for miss in group:
158
+ for symbol, gaps in miss.missing.items():
159
+ missing.setdefault(symbol, []).extend(gaps)
160
+ combined.append(
161
+ CacheMiss(
162
+ missing,
163
+ timeframe,
164
+ date_shortfall=any(m.date_shortfall for m in group),
165
+ cache_dir=group[0].cache_dir,
166
+ )
167
+ )
168
+ return combined
169
+
170
+
171
+ def _warm_window(first: pd.Timestamp, last: pd.Timestamp) -> Tuple[str, str]:
172
+ """The market dates a ``cache warm`` needs to cover ``[first, last]``.
173
+
174
+ Both flags parse a bare date as New York midnight, so the start is the date ``first``
175
+ falls on and the end is the date ``last`` falls on, or the next one when ``last`` is
176
+ later in the day — an end on the day itself would stop at its midnight.
177
+ """
178
+ from datetime import timedelta
179
+
180
+ from quantred.utils.timeutils import NEW_YORK
181
+
182
+ begin = first.tz_convert(NEW_YORK)
183
+ finish = last.tz_convert(NEW_YORK)
184
+ end_day = finish.date() if finish.time() == datetime.min.time() else finish.date() + timedelta(days=1)
185
+ return begin.date().isoformat(), end_day.isoformat()
186
+
187
+
188
+ def _to_utc(value: Any) -> pd.Timestamp:
189
+ ts = pd.Timestamp(value)
190
+ return ts.tz_localize("UTC") if ts.tzinfo is None else ts.tz_convert("UTC")
191
+
192
+
193
+ def _iso(ts: pd.Timestamp) -> str:
194
+ return ts.isoformat()
195
+
196
+
197
+ def _normalize_index_utc(frame: pd.DataFrame) -> pd.DataFrame:
198
+ """Localize/convert a bar frame's index to UTC - upstream providers may
199
+ return NY-localized bars (see ``AlpacaMarketData``); normalizing before any
200
+ comparison/concat avoids relying on pandas to reconcile mixed timezones."""
201
+ idx = frame.index
202
+ if idx.tz is None:
203
+ frame = frame.tz_localize("UTC")
204
+ elif str(idx.tz) != "UTC":
205
+ frame = frame.tz_convert("UTC")
206
+ return frame
207
+
208
+
209
+ class BarCoverage:
210
+ """A derived SQLite index of which ``(symbol, timeframe)`` windows have been
211
+ fetched from upstream. Safe to delete: :meth:`rebuild` reconstructs it
212
+ (approximately) from a :class:`~quantred.data.store.ParquetBarStore`."""
213
+
214
+ def __init__(self, db_path: Optional[Any] = None):
215
+ self.db_path = Path(db_path) if db_path else DEFAULT_COVERAGE_DB
216
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
217
+ self._conn = sqlite3.connect(str(self.db_path), timeout=5.0)
218
+ self._conn.row_factory = sqlite3.Row
219
+ self._conn.execute("PRAGMA journal_mode=WAL")
220
+ self._conn.execute("PRAGMA busy_timeout=5000")
221
+ self._conn.executescript(_SCHEMA)
222
+ self._conn.commit()
223
+
224
+ def close(self) -> None:
225
+ self._conn.close()
226
+
227
+ def __enter__(self) -> "BarCoverage":
228
+ return self
229
+
230
+ def __exit__(self, *exc: Any) -> None:
231
+ self.close()
232
+
233
+ # ------------------------------------------------------------------ #
234
+ # Writing
235
+ # ------------------------------------------------------------------ #
236
+ def record_fetch(
237
+ self,
238
+ symbol: str,
239
+ timeframe: str,
240
+ start: Any,
241
+ end: Any,
242
+ adjustment: str,
243
+ fetched_at: Optional[Any] = None,
244
+ ) -> None:
245
+ """Record that ``[start, end]`` was queried from upstream for ``symbol`` -
246
+ even when the response was empty (a weekend must never look "not yet
247
+ checked" and get re-queried forever)."""
248
+ stamp = _to_utc(fetched_at) if fetched_at is not None else pd.Timestamp.now(tz="UTC")
249
+ self._conn.execute(
250
+ "INSERT INTO fetches (symbol, timeframe, start, end, adjustment, fetched_at) "
251
+ "VALUES (?, ?, ?, ?, ?, ?)",
252
+ (symbol, timeframe, _iso(_to_utc(start)), _iso(_to_utc(end)), adjustment, _iso(stamp)),
253
+ )
254
+ self._conn.commit()
255
+
256
+ def forget(self, symbol: str, timeframe: str) -> None:
257
+ """Delete all fetch rows for ``(symbol, timeframe)`` - used by ``refresh()``
258
+ so a corporate-action re-fetch is never treated as "already covered"."""
259
+ self._conn.execute("DELETE FROM fetches WHERE symbol = ? AND timeframe = ?", (symbol, timeframe))
260
+ self._conn.commit()
261
+
262
+ # ------------------------------------------------------------------ #
263
+ # Reading
264
+ # ------------------------------------------------------------------ #
265
+ def covered_intervals(self, symbol: str, timeframe: str) -> List[Tuple[pd.Timestamp, pd.Timestamp]]:
266
+ """Merge-on-read: the stored fetch rows for ``(symbol, timeframe)``, sorted
267
+ and coalesced into non-overlapping, non-touching intervals."""
268
+ rows = self._conn.execute(
269
+ "SELECT start, end FROM fetches WHERE symbol = ? AND timeframe = ? ORDER BY start",
270
+ (symbol, timeframe),
271
+ ).fetchall()
272
+ merged: List[Tuple[pd.Timestamp, pd.Timestamp]] = []
273
+ for row in rows:
274
+ s, e = _to_utc(row["start"]), _to_utc(row["end"])
275
+ if merged and s <= merged[-1][1]:
276
+ merged[-1] = (merged[-1][0], max(merged[-1][1], e))
277
+ else:
278
+ merged.append((s, e))
279
+ return merged
280
+
281
+ def gaps(
282
+ self, symbol: str, timeframe: str, start: Any, end: Any
283
+ ) -> List[Tuple[pd.Timestamp, pd.Timestamp]]:
284
+ """The sub-ranges of ``[start, end]`` not yet covered by a recorded fetch.
285
+
286
+ Cached ``[b, c]``, requested ``[a, d]`` -> ``[(a, b), (c, d)]`` - only the
287
+ genuinely missing pieces, never a full refetch of an already-cached window.
288
+ """
289
+ start, end = _to_utc(start), _to_utc(end)
290
+ if end <= start:
291
+ return []
292
+ covered = self.covered_intervals(symbol, timeframe)
293
+ out: List[Tuple[pd.Timestamp, pd.Timestamp]] = []
294
+ cursor = start
295
+ for s, e in covered:
296
+ if e <= cursor or s >= end:
297
+ continue
298
+ if s > cursor:
299
+ out.append((cursor, min(s, end)))
300
+ cursor = max(cursor, e)
301
+ if cursor >= end:
302
+ break
303
+ if cursor < end:
304
+ out.append((cursor, end))
305
+ return out
306
+
307
+ def vintage(self, symbols: Iterable[str], timeframe: str, start: Any, end: Any) -> Optional[str]:
308
+ """The data-vintage stamp for this exact window: ``max(fetched_at)`` over
309
+ every recorded fetch overlapping ``[start, end]``, across ``symbols``.
310
+
311
+ Deterministic and stable across repeated warm/offline calls to the same
312
+ window (no new fetch happens -> the same rows cover it -> the same stamp);
313
+ it only changes when :meth:`~CachedMarketData.refresh` forces a real
314
+ re-fetch. ``None`` when nothing has been recorded yet for this window.
315
+ """
316
+ start, end = _to_utc(start), _to_utc(end)
317
+ latest: Optional[pd.Timestamp] = None
318
+ for symbol in dict.fromkeys(symbols):
319
+ rows = self._conn.execute(
320
+ "SELECT start, end, fetched_at FROM fetches WHERE symbol = ? AND timeframe = ?",
321
+ (symbol, timeframe),
322
+ ).fetchall()
323
+ for row in rows:
324
+ s, e = _to_utc(row["start"]), _to_utc(row["end"])
325
+ if e <= start or s >= end:
326
+ continue
327
+ stamp = _to_utc(row["fetched_at"])
328
+ if latest is None or stamp > latest:
329
+ latest = stamp
330
+ return _iso(latest) if latest is not None else None
331
+
332
+ # ------------------------------------------------------------------ #
333
+ # Derived-store precedent: rebuild + drift check
334
+ # ------------------------------------------------------------------ #
335
+ def rebuild(
336
+ self, bar_store: "ParquetBarStore", symbols: Iterable[str], timeframe: str, adjustment: str
337
+ ) -> Dict[str, int]:
338
+ """Reconstruct coverage from the Parquet store alone.
339
+
340
+ An approximation, not an exact replay (the Parquet store has no record of
341
+ *when* something was fetched, or of a queried-but-empty range): each
342
+ symbol's coverage becomes a single interval spanning
343
+ ``[min(ts), max(ts)]`` of whatever is on disk, stamped with the rebuild
344
+ time. This assumes no internal fetch gap inside that span - true unless a
345
+ corporate-action refresh carved one out - the same honesty tradeoff
346
+ ``TrialStore.rebuild()`` already accepts for its own derived index.
347
+ """
348
+ self._conn.execute("DELETE FROM fetches")
349
+ self._conn.commit()
350
+ n_symbols = 0
351
+ for symbol in dict.fromkeys(symbols):
352
+ scanned = bar_store.scan(
353
+ [symbol], timeframe, _FAR_FUTURE, lookback_days=_FULL_HISTORY_LOOKBACK_DAYS
354
+ )
355
+ frame = scanned.get(symbol)
356
+ if frame is None or frame.empty:
357
+ continue
358
+ self.record_fetch(symbol, str(timeframe), frame.index.min(), frame.index.max(), adjustment)
359
+ n_symbols += 1
360
+ return {"symbols": n_symbols}
361
+
362
+ def status(self, bar_store: Optional["ParquetBarStore"] = None) -> Dict[str, Any]:
363
+ """Row counts, plus (when ``bar_store`` is given) a drift check: does the
364
+ Parquet store actually still hold data for what coverage claims is cached?
365
+ """
366
+ rows = self._conn.execute(
367
+ "SELECT symbol, timeframe, MIN(start) AS lo, MAX(end) AS hi, MAX(fetched_at) AS last_fetch "
368
+ "FROM fetches GROUP BY symbol, timeframe ORDER BY symbol, timeframe"
369
+ ).fetchall()
370
+ entries = [dict(r) for r in rows]
371
+ drift: List[str] = []
372
+ if bar_store is not None:
373
+ for entry in entries:
374
+ scanned = bar_store.scan(
375
+ [entry["symbol"]],
376
+ entry["timeframe"],
377
+ _FAR_FUTURE,
378
+ lookback_days=_FULL_HISTORY_LOOKBACK_DAYS,
379
+ )
380
+ if entry["symbol"] not in scanned or scanned[entry["symbol"]].empty:
381
+ drift.append(f"{entry['symbol']} ({entry['timeframe']})")
382
+ return {"db_path": str(self.db_path), "entries": entries, "drift": drift}
383
+
384
+
385
+ def _as_timeframe(timeframe: TimeframeLike) -> Timeframe:
386
+ """The parsed form, from either spelling a caller may hand in.
387
+
388
+ Callers used to write this out and immediately stringify it, then `_fill_gaps`
389
+ parsed the string back - so a value was parsed, flattened and re-parsed on the way
390
+ to the provider that declares it wants the parsed form.
391
+ """
392
+ return Timeframe.parse(timeframe) if isinstance(timeframe, str) else timeframe
393
+
394
+
395
+ class CachedMarketData(MarketDataProvider):
396
+ """A :class:`MarketDataProvider` that transparently caches OHLCV bars on the
397
+ Parquet/DuckDB substrate, fetching only missing date ranges.
398
+
399
+ ``offline=True`` forbids any network call: a request touching an uncached
400
+ range raises :class:`CacheMiss` instead of falling through to ``upstream`` -
401
+ the byte-reproducible, cache-only mode.
402
+ """
403
+
404
+ def __init__(
405
+ self,
406
+ upstream: MarketDataProvider,
407
+ cache_dir: Optional[Any] = None,
408
+ *,
409
+ coverage_db: Optional[Any] = None,
410
+ offline: bool = False,
411
+ adjustment: str = "split",
412
+ ):
413
+ from quantred.data.store import ParquetBarStore
414
+ from quantred.utils.extras import require_extra
415
+
416
+ # Here, at the start of the capability: the Parquet libraries are imported only
417
+ # on the first read or write, so following an offline miss's own `cache warm`
418
+ # hint without the extra ended in a pyarrow traceback.
419
+ require_extra("The bar cache (--cache, --offline, cache warm)", "store")
420
+ self.upstream = upstream
421
+ self.offline = offline
422
+ self.adjustment = adjustment
423
+ self.cache_dir = Path(cache_dir) if cache_dir else DEFAULT_CACHE_ROOT
424
+ self.store = ParquetBarStore(self.cache_dir)
425
+ self.coverage = BarCoverage(
426
+ coverage_db or self.cache_dir.parent / f"{self.cache_dir.name}_coverage.db"
427
+ )
428
+
429
+ def close(self) -> None:
430
+ self.coverage.close()
431
+
432
+ def __enter__(self) -> "CachedMarketData":
433
+ return self
434
+
435
+ def __exit__(self, *exc: Any) -> None:
436
+ self.close()
437
+
438
+ # ------------------------------------------------------------------ #
439
+ # MarketDataProvider
440
+ # ------------------------------------------------------------------ #
441
+ def get_bars(
442
+ self, symbols: List[str], timeframe: TimeframeLike, start: datetime, end: datetime
443
+ ) -> Dict[str, pd.DataFrame]:
444
+ tf_obj = _as_timeframe(timeframe)
445
+ tf = str(tf_obj)
446
+ start_ts, end_ts = _to_utc(start), _to_utc(end)
447
+ if self.offline:
448
+ self._refuse_uncached(list(dict.fromkeys(symbols)), tf, start_ts, end_ts)
449
+ result: Dict[str, pd.DataFrame] = {}
450
+ for symbol in dict.fromkeys(symbols):
451
+ self._fill_gaps(symbol, tf_obj, start_ts, end_ts)
452
+ scanned = self.store.scan(
453
+ [symbol], tf, end_ts.to_pydatetime(), lookback_days=self._lookback_days(start_ts, end_ts)
454
+ )
455
+ frame = scanned.get(symbol)
456
+ if frame is not None and not frame.empty:
457
+ frame = frame.loc[(frame.index >= start_ts) & (frame.index <= end_ts)]
458
+ if not frame.empty:
459
+ result[symbol] = frame
460
+ return result
461
+
462
+ async def stream_bars(self, symbols: List[str], handler: BarHandler) -> None:
463
+ if self.offline:
464
+ raise RuntimeError("--offline: live streaming needs a network connection")
465
+ await self.upstream.stream_bars(symbols, handler)
466
+
467
+ def supports_streaming(self) -> bool:
468
+ return False if self.offline else self.upstream.supports_streaming()
469
+
470
+ # ------------------------------------------------------------------ #
471
+ # Gap-fill (the core of the cache)
472
+ # ------------------------------------------------------------------ #
473
+ def _fill_gaps(self, symbol: str, timeframe: Timeframe, start: pd.Timestamp, end: pd.Timestamp) -> None:
474
+ # Takes the parsed form and derives the storage key from it, rather than
475
+ # taking the key and re-parsing: the caller has already done the work, and a
476
+ # second parse is a second chance for the two to disagree about what they name.
477
+ key = str(timeframe)
478
+ gaps = self.coverage.gaps(symbol, key, start, end)
479
+ if not gaps:
480
+ return
481
+ if self.offline:
482
+ self._refuse_uncached([symbol], key, start, end)
483
+
484
+ fetched_frames: List[pd.DataFrame] = []
485
+ for gap_start, gap_end in gaps:
486
+ fetched = self.upstream.get_bars(
487
+ [symbol], timeframe, gap_start.to_pydatetime(), gap_end.to_pydatetime()
488
+ )
489
+ frame = fetched.get(symbol)
490
+ if frame is not None and not frame.empty:
491
+ frame = _normalize_index_utc(frame)
492
+ # Defend against an upstream that returns more than asked (e.g. a
493
+ # fake/test provider that ignores the window): only what falls
494
+ # inside this exact gap is authoritative for what we mark covered.
495
+ sliced = frame.loc[(frame.index >= gap_start) & (frame.index <= gap_end)]
496
+ if not sliced.empty:
497
+ fetched_frames.append(sliced)
498
+ self.coverage.record_fetch(symbol, key, gap_start, gap_end, self.adjustment)
499
+
500
+ if fetched_frames:
501
+ self._merge_write(symbol, key, fetched_frames)
502
+
503
+ def _refuse_uncached(
504
+ self, symbols: List[str], timeframe: str, start: pd.Timestamp, end: pd.Timestamp
505
+ ) -> None:
506
+ """Raise one :class:`CacheMiss` naming every symbol with a gap, before any is read."""
507
+ self.refuse_uncached_reads([(symbols, timeframe, start, end)])
508
+
509
+ def is_cached(self, symbols: List[str], timeframe: TimeframeLike, start: datetime, end: datetime) -> bool:
510
+ """Whether every one of ``symbols`` is covered over ``[start, end]``."""
511
+ tf = str(_as_timeframe(timeframe))
512
+ start_ts, end_ts = _to_utc(start), _to_utc(end)
513
+ return not any(self.coverage.gaps(s, tf, start_ts, end_ts) for s in dict.fromkeys(symbols))
514
+
515
+ def refuse_uncached_reads(
516
+ self,
517
+ reads: List[Tuple[List[str], TimeframeLike, datetime, datetime]],
518
+ *,
519
+ why: Optional[str] = None,
520
+ ) -> None:
521
+ """Raise one :class:`CacheMiss` over several reads a command will make, offline.
522
+
523
+ Each read is ``(symbols, timeframe, start, end)``. Reads of one timeframe share a
524
+ miss, so a command that reads two windows is told about both at once rather than
525
+ one per rerun. ``why`` is a line saying why the miss asks for what it does.
526
+ """
527
+ if not self.offline:
528
+ return
529
+ by_timeframe: Dict[str, Dict[str, List[Tuple[pd.Timestamp, pd.Timestamp]]]] = {}
530
+ for symbols, timeframe, start, end in reads:
531
+ tf = str(_as_timeframe(timeframe))
532
+ start_ts, end_ts = _to_utc(start), _to_utc(end)
533
+ for symbol in dict.fromkeys(symbols):
534
+ gaps = self.coverage.gaps(symbol, tf, start_ts, end_ts)
535
+ if gaps:
536
+ by_timeframe.setdefault(tf, {}).setdefault(symbol, []).extend(gaps)
537
+ # One timeframe per miss: a warm command takes one, so a command reading two
538
+ # would need two rounds whatever the message said.
539
+ for tf, missing in by_timeframe.items():
540
+ shortfall = any(_is_date_shortfall(gap) for gaps in missing.values() for gap in gaps)
541
+ raise CacheMiss(missing, tf, date_shortfall=shortfall, cache_dir=self.cache_dir, why=why)
542
+
543
+ def _merge_write(self, symbol: str, timeframe: str, new_frames: List[pd.DataFrame]) -> None:
544
+ """Read the symbol's full existing history, merge in the newly-fetched
545
+ frames, and write the union back.
546
+
547
+ Required because ``ParquetBarStore.write()`` replaces a symbol's *entire*
548
+ subtree on every call (by design - no stale year partitions survive a
549
+ rewrite); writing only the new gap would silently delete every other
550
+ year already cached for this symbol.
551
+ """
552
+ existing = self.store.scan(
553
+ [symbol], timeframe, _FAR_FUTURE, lookback_days=_FULL_HISTORY_LOOKBACK_DAYS
554
+ )
555
+ parts = [existing[symbol]] if symbol in existing and not existing[symbol].empty else []
556
+ parts.extend(new_frames)
557
+ if not parts:
558
+ return
559
+ merged = pd.concat(parts)
560
+ merged = merged[~merged.index.duplicated(keep="last")].sort_index()
561
+ self.store.write({symbol: merged}, timeframe)
562
+
563
+ @staticmethod
564
+ def _lookback_days(start: pd.Timestamp, end: pd.Timestamp) -> int:
565
+ return max(int((end - start) / pd.Timedelta(days=1)) + 2, 1)
566
+
567
+ # ------------------------------------------------------------------ #
568
+ # CLI-facing helpers
569
+ # ------------------------------------------------------------------ #
570
+ def vintage_stamp(
571
+ self, symbols: List[str], timeframe: TimeframeLike, start: datetime, end: datetime
572
+ ) -> Optional[str]:
573
+ """Ensure ``[start, end]`` is cached for ``symbols`` (gap-filling exactly as
574
+ :meth:`get_bars` would), then return the data-vintage stamp for that
575
+ window - suitable for folding into a trial's dedup key."""
576
+ tf_obj = _as_timeframe(timeframe)
577
+ tf = str(tf_obj)
578
+ start_ts, end_ts = _to_utc(start), _to_utc(end)
579
+ if self.offline:
580
+ # Up front, as in get_bars: backtest, optimize and walkforward stamp the
581
+ # vintage before reading any bars, so this is where their miss is raised.
582
+ self._refuse_uncached(list(dict.fromkeys(symbols)), tf, start_ts, end_ts)
583
+ for symbol in dict.fromkeys(symbols):
584
+ self._fill_gaps(symbol, tf_obj, start_ts, end_ts)
585
+ return self.coverage.vintage(symbols, tf, start_ts, end_ts)
586
+
587
+ def warm(
588
+ self, symbols: List[str], timeframe: TimeframeLike, start: datetime, end: datetime
589
+ ) -> Dict[str, Any]:
590
+ """Explicit prefetch for ``cache warm``: fetch and cache ``[start, end]``
591
+ for ``symbols``, returning a summary of what was already covered vs.
592
+ newly fetched."""
593
+ tf_obj = _as_timeframe(timeframe)
594
+ tf = str(tf_obj)
595
+ start_ts, end_ts = _to_utc(start), _to_utc(end)
596
+ summary: Dict[str, Any] = {}
597
+ for symbol in dict.fromkeys(symbols):
598
+ gaps_before = self.coverage.gaps(symbol, tf, start_ts, end_ts)
599
+ self._fill_gaps(symbol, tf_obj, start_ts, end_ts)
600
+ summary[symbol] = {"already_cached": not gaps_before, "gaps_fetched": len(gaps_before)}
601
+ return summary
602
+
603
+ def refresh(
604
+ self,
605
+ symbols: List[str],
606
+ timeframe: TimeframeLike,
607
+ start: Optional[datetime] = None,
608
+ end: Optional[datetime] = None,
609
+ ) -> Dict[str, Any]:
610
+ """Invalidate and re-fetch ``symbols`` - the corporate-action lever: a
611
+ split/dividend backfill since the original fetch is never silently
612
+ served stale. Re-fetches ``[start, end]`` if given, otherwise the
613
+ symbol's previously-known covered extent (or is a no-op if neither is
614
+ available)."""
615
+ tf_obj = _as_timeframe(timeframe)
616
+ tf = str(tf_obj)
617
+ summary: Dict[str, Any] = {}
618
+ for symbol in dict.fromkeys(symbols):
619
+ window = self._resolve_refresh_window(symbol, tf, start, end)
620
+ self.store.delete_symbol(symbol, tf)
621
+ self.coverage.forget(symbol, tf)
622
+ if window is None:
623
+ summary[symbol] = {
624
+ "refreshed": False,
625
+ "reason": "no prior coverage and no --start/--end given",
626
+ }
627
+ continue
628
+ self._fill_gaps(symbol, tf_obj, window[0], window[1])
629
+ summary[symbol] = {"refreshed": True, "start": _iso(window[0]), "end": _iso(window[1])}
630
+ return summary
631
+
632
+ def _resolve_refresh_window(
633
+ self, symbol: str, timeframe: str, start: Optional[datetime], end: Optional[datetime]
634
+ ) -> Optional[Tuple[pd.Timestamp, pd.Timestamp]]:
635
+ if start is not None and end is not None:
636
+ return _to_utc(start), _to_utc(end)
637
+ covered = self.coverage.covered_intervals(symbol, timeframe)
638
+ if not covered:
639
+ return None
640
+ return covered[0][0], covered[-1][1]
641
+
642
+ def status(
643
+ self, symbols: Optional[List[str]] = None, timeframe: Optional[TimeframeLike] = None
644
+ ) -> Dict[str, Any]:
645
+ """Coverage summary for ``cache status`` - all cached symbols/timeframes,
646
+ or a filtered subset, plus a drift check against the Parquet store."""
647
+ info = self.coverage.status(self.store)
648
+ entries = info["entries"]
649
+ if symbols is not None:
650
+ wanted = set(symbols)
651
+ entries = [e for e in entries if e["symbol"] in wanted]
652
+ if timeframe is not None:
653
+ tf = str(_as_timeframe(timeframe))
654
+ entries = [e for e in entries if e["timeframe"] == tf]
655
+ return {
656
+ "db_path": info["db_path"],
657
+ "cache_dir": str(self.cache_dir),
658
+ "entries": entries,
659
+ "drift": info["drift"],
660
+ }