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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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
|
+
}
|