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/data/edges.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The pandas edge — the *only* sanctioned crossings between the lazy columnar core
|
|
2
|
+
and pandas.
|
|
3
|
+
|
|
4
|
+
The lazy compute model keeps the panel in Arrow/Polars and never
|
|
5
|
+
materializes the whole thing into pandas. But pandas is still the right currency at
|
|
6
|
+
two narrow places: a **per-symbol leaf** where the data is provably small (the
|
|
7
|
+
indicator math the strategy already speaks), and the **final report** a human reads.
|
|
8
|
+
|
|
9
|
+
Routing every crossing through this module makes the edge *greppable*: a
|
|
10
|
+
``to_pandas`` on a full panel is a bug, not a convenience, and a reviewer can find
|
|
11
|
+
every place the column store collapses to row-major pandas by searching for these
|
|
12
|
+
two functions. Nothing else in the codebase should call ``.to_pandas()`` /
|
|
13
|
+
``pl.from_pandas`` on panel-wide data directly.
|
|
14
|
+
|
|
15
|
+
Requires the ``store`` extra (``polars``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING, Optional, Union
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
23
|
+
import polars as pl
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_pandas(
|
|
27
|
+
frame: Union["pl.DataFrame", "pl.LazyFrame"],
|
|
28
|
+
*,
|
|
29
|
+
index: Optional[str] = None,
|
|
30
|
+
streaming: bool = True,
|
|
31
|
+
) -> pd.DataFrame:
|
|
32
|
+
"""Collapse a Polars frame to pandas — the sanctioned core→edge crossing.
|
|
33
|
+
|
|
34
|
+
Accepts either an eager :class:`polars.DataFrame` or a :class:`polars.LazyFrame`;
|
|
35
|
+
a lazy frame is collected first (with the streaming engine by default, so a large
|
|
36
|
+
plan materializes in bounded-memory chunks rather than all at once). Pass
|
|
37
|
+
``index`` to set a column as the resulting pandas index (e.g. ``"ts"`` for a
|
|
38
|
+
per-symbol leaf, ``"symbol"`` for a one-timestamp cross-section).
|
|
39
|
+
|
|
40
|
+
This is a deliberate narrowing point: call it at a small per-symbol leaf or the
|
|
41
|
+
final report, never on the full multi-symbol panel.
|
|
42
|
+
"""
|
|
43
|
+
import polars as pl
|
|
44
|
+
|
|
45
|
+
if isinstance(frame, pl.LazyFrame):
|
|
46
|
+
frame = collect_streaming(frame) if streaming else frame.collect()
|
|
47
|
+
pdf = frame.to_pandas()
|
|
48
|
+
if index is not None:
|
|
49
|
+
if index not in pdf.columns:
|
|
50
|
+
raise KeyError(f"index column {index!r} not in frame; columns are {list(pdf.columns)}")
|
|
51
|
+
pdf = pdf.set_index(index)
|
|
52
|
+
return pdf
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def from_pandas(df: pd.DataFrame, *, include_index: bool = True) -> "pl.DataFrame":
|
|
56
|
+
"""Lift a pandas frame into Polars — the sanctioned edge→core crossing.
|
|
57
|
+
|
|
58
|
+
Used to bring a per-symbol leaf (or a provider frame) back into the columnar
|
|
59
|
+
core. By default the pandas index is materialized as a column; pass
|
|
60
|
+
``include_index=False`` to drop it. A provider/OHLCV frame is typically indexed by
|
|
61
|
+
an *unnamed* ``DatetimeIndex`` — Polars would materialize that as a column literally
|
|
62
|
+
named ``"None"``, so we name it first: a ``DatetimeIndex`` becomes ``ts`` (the
|
|
63
|
+
promise that a time index survives as a real ``ts`` column), any other unnamed
|
|
64
|
+
index becomes ``index``.
|
|
65
|
+
"""
|
|
66
|
+
import polars as pl
|
|
67
|
+
|
|
68
|
+
if include_index and df.index.name is None:
|
|
69
|
+
df = df.rename_axis("ts" if isinstance(df.index, pd.DatetimeIndex) else "index")
|
|
70
|
+
return pl.from_pandas(df, include_index=include_index)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def collect_streaming(lazy: "pl.LazyFrame") -> "pl.DataFrame":
|
|
74
|
+
"""Materialize a :class:`polars.LazyFrame` via the **streaming** engine.
|
|
75
|
+
|
|
76
|
+
The streaming engine processes the query plan in bounded-memory chunks, so a plan
|
|
77
|
+
over a larger-than-RAM panel collects without ever holding the whole result. This
|
|
78
|
+
is the one terminal the lazy helpers in :mod:`quantred.data.compute` are meant to end
|
|
79
|
+
on; it lives here (next to ``to_pandas``) because materializing *is* the edge.
|
|
80
|
+
"""
|
|
81
|
+
return lazy.collect(engine="streaming")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Feature producers - populate a :class:`FeaturePanel`'s columns from scanned bars.
|
|
2
|
+
|
|
3
|
+
Each producer is the cross-sectional analog of an indicator: it reads the
|
|
4
|
+
point-in-time bars for the universe and writes one or more feature columns. Three
|
|
5
|
+
today - a risk producer (beta + residual volatility), a factor-exposure producer
|
|
6
|
+
(the risk model's standardized exposures, for factor-neutral alphas), and a
|
|
7
|
+
generic score producer (apply any per-name scorer). New producers (liquidity,
|
|
8
|
+
transaction-cost params) slot in the same way without the consumers changing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Callable, Dict, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from quantred.analytics import metrics as m
|
|
16
|
+
from quantred.data.panel import FeaturePanel
|
|
17
|
+
from quantred.indicators import indicators
|
|
18
|
+
|
|
19
|
+
#: A scorer reads one symbol's (as-of-sliced) bars and returns a continuous score.
|
|
20
|
+
Scorer = Callable[[pd.DataFrame], float]
|
|
21
|
+
|
|
22
|
+
#: Panel column prefix for factor-exposure features (``exp_market``, ``exp_size``, ...).
|
|
23
|
+
EXPOSURE_PREFIX = "exp_"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def add_risk_features(
|
|
27
|
+
panel: FeaturePanel,
|
|
28
|
+
bars: Dict[str, pd.DataFrame],
|
|
29
|
+
benchmark_bars: Optional[pd.DataFrame],
|
|
30
|
+
periods_per_year: float,
|
|
31
|
+
default_residual_vol: float = 0.20,
|
|
32
|
+
) -> FeaturePanel:
|
|
33
|
+
"""Write ``beta`` and ``residual_vol`` columns (annualized) for each name.
|
|
34
|
+
|
|
35
|
+
Residual volatility strips the benchmark-explained part of each return; it is
|
|
36
|
+
the ``sigma`` the alpha-scaling identity needs. With no benchmark series
|
|
37
|
+
available it falls back to total volatility (beta unknown, set to 1.0) and
|
|
38
|
+
records ``benchmark_available=False`` in the panel meta.
|
|
39
|
+
|
|
40
|
+
**The alpha/risk vol seam.** This ``residual_vol`` is
|
|
41
|
+
computed **unconditionally** (a flat trailing-window figure) and deliberately
|
|
42
|
+
stays that way — it scales alpha magnitude (``α = ω·IC·z``, Case 1) and the
|
|
43
|
+
refinement's Case logic, and an alpha shouldn't double in size just because last week was
|
|
44
|
+
wild. Only the *risk-model* Σ used for portfolio construction / tracking-error
|
|
45
|
+
(:mod:`quantred.risk.conditional`) conditions on recent volatility. Do not "unify"
|
|
46
|
+
the two into one vol-squared concept — that reintroduces exactly the position-
|
|
47
|
+
doubling-in-a-vol-spike bug this separation exists to prevent.
|
|
48
|
+
"""
|
|
49
|
+
available = benchmark_bars is not None and not benchmark_bars.empty
|
|
50
|
+
betas: Dict[str, float] = {}
|
|
51
|
+
vols: Dict[str, float] = {}
|
|
52
|
+
for symbol in panel.symbols:
|
|
53
|
+
frame = bars.get(symbol)
|
|
54
|
+
if frame is None or frame.empty:
|
|
55
|
+
continue
|
|
56
|
+
close = frame["close"]
|
|
57
|
+
if available:
|
|
58
|
+
beta = indicators.calculate_beta(close, benchmark_bars["close"])
|
|
59
|
+
vol = indicators.calculate_residual_volatility(
|
|
60
|
+
close, benchmark_bars["close"], beta, periods_per_year
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
beta = 1.0
|
|
64
|
+
vol = m.annualized_volatility(close.pct_change().dropna(), int(round(periods_per_year)))
|
|
65
|
+
betas[symbol] = beta
|
|
66
|
+
vols[symbol] = vol or default_residual_vol
|
|
67
|
+
|
|
68
|
+
panel.set("beta", betas)
|
|
69
|
+
panel.set("residual_vol", vols)
|
|
70
|
+
panel.meta["benchmark_available"] = available
|
|
71
|
+
return panel
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def add_factor_exposure_features(
|
|
75
|
+
panel: FeaturePanel,
|
|
76
|
+
bars: Dict[str, pd.DataFrame],
|
|
77
|
+
benchmark_bars: Optional[pd.DataFrame],
|
|
78
|
+
factors: Sequence[str],
|
|
79
|
+
) -> FeaturePanel:
|
|
80
|
+
"""Write standardized risk-model factor exposures as ``exp_<factor>`` columns.
|
|
81
|
+
|
|
82
|
+
The columns come from the same exposure builder the factor risk model uses
|
|
83
|
+
(:func:`quantred.risk.exposures.build_factor_exposures`), so "factor-neutral" in the
|
|
84
|
+
alpha pipeline means neutral to the *risk model's* factors — one definition,
|
|
85
|
+
both places. The market factor reuses the panel's ``beta`` column when present
|
|
86
|
+
(same regression, already run by :func:`add_risk_features`). If the build
|
|
87
|
+
qualifies fewer than two names, **no columns are written** — the refinement then
|
|
88
|
+
falls back to plain-beta neutralization rather than silently doing nothing.
|
|
89
|
+
Names missing a single factor get a NaN exposure (mean-imputed downstream).
|
|
90
|
+
"""
|
|
91
|
+
from quantred.risk.exposures import build_factor_exposures
|
|
92
|
+
|
|
93
|
+
if not list(factors):
|
|
94
|
+
return panel
|
|
95
|
+
betas = panel.get("beta") if panel.has("beta") else None
|
|
96
|
+
frame = build_factor_exposures(bars, benchmark_bars, factors=list(factors), betas=betas)
|
|
97
|
+
if frame.empty:
|
|
98
|
+
return panel
|
|
99
|
+
for name in frame.columns:
|
|
100
|
+
panel.set(f"{EXPOSURE_PREFIX}{name}", frame[name])
|
|
101
|
+
return panel
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def add_score_feature(
|
|
105
|
+
panel: FeaturePanel,
|
|
106
|
+
scorer: Scorer,
|
|
107
|
+
bars: Dict[str, pd.DataFrame],
|
|
108
|
+
column: str = "score",
|
|
109
|
+
) -> FeaturePanel:
|
|
110
|
+
"""Write a continuous ``score`` column by applying ``scorer`` to each name's bars."""
|
|
111
|
+
scores: Dict[str, float] = {}
|
|
112
|
+
for symbol in panel.symbols:
|
|
113
|
+
frame = bars.get(symbol)
|
|
114
|
+
if frame is None or frame.empty:
|
|
115
|
+
continue
|
|
116
|
+
value = scorer(frame)
|
|
117
|
+
if value is not None and not pd.isna(value):
|
|
118
|
+
scores[symbol] = float(value)
|
|
119
|
+
panel.set(column, scores)
|
|
120
|
+
return panel
|
quantred/data/panel.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The cross-sectional feature panel - all the data in one place, as of one moment.
|
|
2
|
+
|
|
3
|
+
A :class:`FeaturePanel` is the single table every cross-sectional module reads from
|
|
4
|
+
and writes to: rows are symbols, columns are features (a signal's score, residual
|
|
5
|
+
volatility, beta, factor exposures, and - once refined - z and alpha). It is the
|
|
6
|
+
unit that makes the active-management stack cohesive: a strategy/scanner *produces*
|
|
7
|
+
a score column, the risk layer produces beta/residual_vol, the alpha layer consumes
|
|
8
|
+
those and produces z/alpha, and portfolio construction consumes the whole row.
|
|
9
|
+
Stacked over time these panels are the data a factor-importance or signal-combination
|
|
10
|
+
search runs over - "which factors matter right now" is a query against the panel.
|
|
11
|
+
|
|
12
|
+
Point-in-time by construction: a panel is built from a :mod:`quantred.data.scan` at one
|
|
13
|
+
``as_of``, so every column is leakage-safe. Today it wraps a pandas frame (small
|
|
14
|
+
universes); the same shape is what an Arrow/Polars columnar store would back at
|
|
15
|
+
scale, so the consumers above never learn where the data physically lives.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Dict, List, Mapping, Union
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
Values = Union[pd.Series, Mapping[str, float]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FeaturePanel:
|
|
29
|
+
"""A symbol-indexed table of features for one universe at one ``as_of``."""
|
|
30
|
+
|
|
31
|
+
as_of: datetime
|
|
32
|
+
features: pd.DataFrame
|
|
33
|
+
#: Cross-sectional flags set by producers/refiners (e.g. low_confidence).
|
|
34
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def for_universe(cls, as_of: datetime, symbols: List[str]) -> "FeaturePanel":
|
|
38
|
+
"""An empty panel whose rows are ``symbols`` (column-less until populated)."""
|
|
39
|
+
index = pd.Index(list(dict.fromkeys(symbols)), name="symbol")
|
|
40
|
+
return cls(as_of=as_of, features=pd.DataFrame(index=index))
|
|
41
|
+
|
|
42
|
+
def set(self, name: str, values: Values) -> "FeaturePanel":
|
|
43
|
+
"""Add/replace a feature column, aligned to the panel's symbols."""
|
|
44
|
+
series = values if isinstance(values, pd.Series) else pd.Series(values, dtype=float)
|
|
45
|
+
self.features[name] = series.reindex(self.features.index)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def get(self, name: str) -> pd.Series:
|
|
49
|
+
"""Return a feature column (raises ``KeyError`` if absent)."""
|
|
50
|
+
return self.features[name]
|
|
51
|
+
|
|
52
|
+
def has(self, name: str) -> bool:
|
|
53
|
+
return name in self.features.columns
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def symbols(self) -> List[str]:
|
|
57
|
+
return list(self.features.index)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def columns(self) -> List[str]:
|
|
61
|
+
return list(self.features.columns)
|
quantred/data/scan.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The data-access seam - one place to scan bars point-in-time.
|
|
2
|
+
|
|
3
|
+
Every cross-sectional research flow (alphas, and soon risk / portfolio /
|
|
4
|
+
information analysis) needs the same thing: the bars for a universe *as of* a
|
|
5
|
+
rebalance timestamp, with nothing after it. This module is that single seam, and
|
|
6
|
+
the single home of the leakage guard (``<= as_of``) - so no call site re-implements
|
|
7
|
+
slicing and accidentally lets a future bar through.
|
|
8
|
+
|
|
9
|
+
Today it is pandas-backed over :class:`MarketDataClient`. The ``scan(universe,
|
|
10
|
+
timeframe, as_of, lookback)`` signature is deliberately the same contract an
|
|
11
|
+
out-of-core Arrow/Polars/DuckDB source would implement, so growing the storage
|
|
12
|
+
tier is a new adapter behind this interface, not a rewrite of the layers above.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Dict, List, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from quantred.marketdata.client import MarketDataClient, TimeframeLike
|
|
21
|
+
from quantred.utils.timeutils import shift_days
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class BarSource(Protocol):
|
|
26
|
+
"""A point-in-time bar source: bars for a universe, never past ``as_of``."""
|
|
27
|
+
|
|
28
|
+
def scan(
|
|
29
|
+
self, universe: List[str], timeframe: TimeframeLike, as_of: datetime, lookback_days: int
|
|
30
|
+
) -> Dict[str, pd.DataFrame]:
|
|
31
|
+
"""Return ``{symbol: OHLCV}`` over ``(as_of - lookback, as_of]`` per symbol."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ClientBarSource:
|
|
36
|
+
"""A :class:`BarSource` backed by the existing :class:`MarketDataClient`."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, data_client: MarketDataClient):
|
|
39
|
+
self._client = data_client
|
|
40
|
+
|
|
41
|
+
def scan(
|
|
42
|
+
self,
|
|
43
|
+
universe: List[str],
|
|
44
|
+
timeframe: TimeframeLike,
|
|
45
|
+
as_of: datetime,
|
|
46
|
+
lookback_days: int = 365,
|
|
47
|
+
) -> Dict[str, pd.DataFrame]:
|
|
48
|
+
# Fetch with end == as_of (defends leakage for real feeds), then slice
|
|
49
|
+
# defensively (synthetic/fake feeds ignore the window, so the slice is the
|
|
50
|
+
# real guard). De-dup the universe so a benchmark listed twice is fetched once.
|
|
51
|
+
start = shift_days(as_of, -lookback_days)
|
|
52
|
+
raw = self._client.get_bars(list(dict.fromkeys(universe)), timeframe, start, as_of)
|
|
53
|
+
out: Dict[str, pd.DataFrame] = {}
|
|
54
|
+
for symbol, frame in raw.items():
|
|
55
|
+
sliced = slice_to_as_of(frame, as_of)
|
|
56
|
+
if sliced is not None and not sliced.empty:
|
|
57
|
+
out[symbol] = sliced
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def slice_to_as_of(frame: pd.DataFrame, as_of: datetime) -> pd.DataFrame:
|
|
62
|
+
"""Return only bars at or before ``as_of`` - the leakage guard.
|
|
63
|
+
|
|
64
|
+
Aligns the cutoff to the frame in **both** directions. Handling only the aware
|
|
65
|
+
index / naive cutoff pair was enough while callers passed naive cutoffs, and
|
|
66
|
+
stopped being enough the moment the scanner began localizing its window
|
|
67
|
+
unconditionally: an aware cutoff against a naive index raised ``Invalid
|
|
68
|
+
comparison between dtype=datetime64[ns] and Timestamp`` on any provider whose
|
|
69
|
+
bar timestamps carry no zone - a frame this guard had always accepted.
|
|
70
|
+
|
|
71
|
+
Being the single seam means owning the whole coercion, not half of it. A naive
|
|
72
|
+
bar index is taken as UTC, the assumption
|
|
73
|
+
:func:`~quantred.utils.timeutils.localize_index_to_new_york` already makes about
|
|
74
|
+
a provider's timestamps.
|
|
75
|
+
"""
|
|
76
|
+
if frame is None or frame.empty:
|
|
77
|
+
return frame
|
|
78
|
+
ts = pd.Timestamp(as_of)
|
|
79
|
+
idx = frame.index
|
|
80
|
+
if isinstance(idx, pd.DatetimeIndex):
|
|
81
|
+
if idx.tz is not None:
|
|
82
|
+
ts = ts.tz_localize(idx.tz) if ts.tzinfo is None else ts.tz_convert(idx.tz)
|
|
83
|
+
elif ts.tzinfo is not None:
|
|
84
|
+
ts = ts.tz_convert("UTC").tz_localize(None)
|
|
85
|
+
return frame.loc[idx <= ts]
|
quantred/data/store.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Out-of-core bar storage: a Parquet/Arrow store behind the same ``scan()`` seam.
|
|
2
|
+
|
|
3
|
+
The load-bearing assumption everywhere else is that an in-memory pandas frame is the
|
|
4
|
+
currency between layers — fine for a few hundred symbols of daily bars, fatal for the
|
|
5
|
+
volumes a real system sees. This module is the first storage tier that scales past
|
|
6
|
+
RAM: bars live in **partitioned Parquet**, and a scan reads them through Arrow with
|
|
7
|
+
**predicate/projection pushdown** — the ``as_of`` filter is applied at the storage
|
|
8
|
+
layer, so a point-in-time scan physically never reads rows after ``as_of``.
|
|
9
|
+
|
|
10
|
+
Crucially it implements the *same* :class:`~quantred.data.scan.BarSource` contract as the
|
|
11
|
+
in-memory :class:`~quantred.data.scan.ClientBarSource`, so it is a **drop-in**: the alpha,
|
|
12
|
+
risk, and portfolio layers above never learn where the data physically lives. pandas
|
|
13
|
+
appears only at the edge (the returned per-symbol frames), never as the cross-layer
|
|
14
|
+
currency. Requires the ``store`` extra (``pyarrow``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from quantred.marketdata.client import TimeframeLike
|
|
25
|
+
from quantred.marketdata.timeframe import Timeframe
|
|
26
|
+
from quantred.utils.timeutils import shift_days
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
29
|
+
import polars as pl
|
|
30
|
+
|
|
31
|
+
#: The enforced bar schema columns (the Arrow boundary contract).
|
|
32
|
+
BAR_COLUMNS = ["ts", "symbol", "open", "high", "low", "close", "volume"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ParquetBarStore:
|
|
36
|
+
"""A point-in-time :class:`BarSource` backed by symbol/date-partitioned Parquet.
|
|
37
|
+
|
|
38
|
+
Layout: ``<root>/<timeframe>/symbol=<SYM>/year=<YYYY>/part.parquet``. One timeframe
|
|
39
|
+
per subtree, then a Hive ``symbol=…/year=…`` partitioning that serves both access
|
|
40
|
+
patterns (a symbol's full history, and a date window across symbols). A scan prunes
|
|
41
|
+
to the **partition files that overlap the date window** (file-level pruning) and
|
|
42
|
+
then pushes the exact ``as_of`` window into the Parquet reader (row-group pruning),
|
|
43
|
+
so a one-year scan of a decade-deep store physically opens ~one year of files.
|
|
44
|
+
|
|
45
|
+
Year is the partition granularity: coarse enough that daily/most-intraday data
|
|
46
|
+
keeps a sane file count, fine enough that a typical lookback window touches one or
|
|
47
|
+
two partitions. Writing a symbol replaces its whole subtree (all years).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, root: str):
|
|
51
|
+
self.root = Path(root)
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------ #
|
|
54
|
+
# Write
|
|
55
|
+
# ------------------------------------------------------------------ #
|
|
56
|
+
def write(self, bars: Dict[str, pd.DataFrame], timeframe: TimeframeLike = "1Day") -> None:
|
|
57
|
+
"""Persist ``{symbol: OHLCV}`` to the store (replacing each symbol's subtree).
|
|
58
|
+
|
|
59
|
+
Each symbol's bars are split into ``year=<YYYY>`` partition files (by the bar's
|
|
60
|
+
UTC year, the same basis the scan window prunes on). The symbol's existing
|
|
61
|
+
subtree is removed first so a rewrite never leaves a stale year partition behind.
|
|
62
|
+
"""
|
|
63
|
+
import pyarrow as pa
|
|
64
|
+
import pyarrow.parquet as pq
|
|
65
|
+
|
|
66
|
+
tf = str(Timeframe.parse(timeframe) if isinstance(timeframe, str) else timeframe)
|
|
67
|
+
for symbol, frame in bars.items():
|
|
68
|
+
if frame is None or frame.empty:
|
|
69
|
+
continue
|
|
70
|
+
records = self._to_records(symbol, frame)
|
|
71
|
+
sdir = self._symbol_dir(tf, symbol)
|
|
72
|
+
if sdir.exists():
|
|
73
|
+
shutil.rmtree(sdir) # full replace: no stale year partitions survive
|
|
74
|
+
for year, group in records.groupby(records["ts"].dt.year):
|
|
75
|
+
part = sdir / f"year={int(year)}"
|
|
76
|
+
part.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
table = pa.Table.from_pandas(group, preserve_index=False)
|
|
78
|
+
pq.write_table(table, part / "part.parquet")
|
|
79
|
+
|
|
80
|
+
def delete_symbol(self, symbol: str, timeframe: TimeframeLike = "1Day") -> None:
|
|
81
|
+
"""Remove a symbol's entire cached subtree (all years) for one timeframe.
|
|
82
|
+
|
|
83
|
+
The corporate-action lever: a split/dividend backfill invalidates
|
|
84
|
+
everything cached for a symbol, not just a sub-range, so the next fetch
|
|
85
|
+
starts clean rather than merging fresh data with stale pre-adjustment bars.
|
|
86
|
+
"""
|
|
87
|
+
tf = str(Timeframe.parse(timeframe) if isinstance(timeframe, str) else timeframe)
|
|
88
|
+
sdir = self._symbol_dir(tf, symbol)
|
|
89
|
+
if sdir.exists():
|
|
90
|
+
shutil.rmtree(sdir)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# Scan (the BarSource contract)
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
def scan(
|
|
96
|
+
self, universe: List[str], timeframe: TimeframeLike, as_of: datetime, lookback_days: int = 365
|
|
97
|
+
) -> Dict[str, pd.DataFrame]:
|
|
98
|
+
"""Return ``{symbol: OHLCV}`` over ``(as_of - lookback, as_of]`` — pushed down.
|
|
99
|
+
|
|
100
|
+
Only the ``year=`` partition files overlapping the window are opened (file-level
|
|
101
|
+
pruning), and the timestamp window is then a Parquet filter, so rows outside it
|
|
102
|
+
(in particular any bar *after* ``as_of``) are never read into memory.
|
|
103
|
+
"""
|
|
104
|
+
import pyarrow.compute as pc
|
|
105
|
+
import pyarrow.dataset as ds
|
|
106
|
+
|
|
107
|
+
tf = str(Timeframe.parse(timeframe) if isinstance(timeframe, str) else timeframe)
|
|
108
|
+
if not (self.root / tf).exists():
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
start, end = self._window(as_of, lookback_days)
|
|
112
|
+
out: Dict[str, pd.DataFrame] = {}
|
|
113
|
+
for symbol in dict.fromkeys(universe):
|
|
114
|
+
files = self._window_files(tf, symbol, start, end)
|
|
115
|
+
if not files:
|
|
116
|
+
continue
|
|
117
|
+
dataset = ds.dataset(files, format="parquet")
|
|
118
|
+
# Predicate pushdown: only ts in (start, end] is read off disk.
|
|
119
|
+
table = dataset.to_table(filter=(pc.field("ts") > start) & (pc.field("ts") <= end))
|
|
120
|
+
if table.num_rows:
|
|
121
|
+
out[symbol] = self._from_records(table.to_pandas())
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ #
|
|
125
|
+
# Partition geometry (symbol=…/year=… Hive layout)
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
def _tf_root(self, timeframe: TimeframeLike) -> Path:
|
|
128
|
+
tf = str(Timeframe.parse(timeframe) if isinstance(timeframe, str) else timeframe)
|
|
129
|
+
return self.root / tf
|
|
130
|
+
|
|
131
|
+
def _symbol_dir(self, tf: str, symbol: str) -> Path:
|
|
132
|
+
return self.root / tf / f"symbol={symbol}"
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _window(as_of: datetime, lookback_days: int) -> tuple:
|
|
136
|
+
"""Normalize ``as_of`` to UTC and return the ``(start, end]`` window."""
|
|
137
|
+
moment = pd.Timestamp(as_of)
|
|
138
|
+
# Shift in the value's own zone first: in UTC, a New York midnight minus N days
|
|
139
|
+
# crosses DST and lands an hour off the boundary.
|
|
140
|
+
start = shift_days(moment, -lookback_days)
|
|
141
|
+
|
|
142
|
+
def utc(t):
|
|
143
|
+
return t.tz_localize("UTC") if t.tzinfo is None else t.tz_convert("UTC")
|
|
144
|
+
|
|
145
|
+
return utc(start), utc(moment)
|
|
146
|
+
|
|
147
|
+
def _year_files(self, sdir: Path, lo_year: Optional[int], hi_year: Optional[int]) -> List[str]:
|
|
148
|
+
"""``part.parquet`` paths in ``sdir``'s ``year=`` partitions within ``[lo, hi]``."""
|
|
149
|
+
if not sdir.exists():
|
|
150
|
+
return []
|
|
151
|
+
files = []
|
|
152
|
+
for ydir in sorted(sdir.glob("year=*")):
|
|
153
|
+
try:
|
|
154
|
+
year = int(ydir.name.split("=", 1)[1])
|
|
155
|
+
except (IndexError, ValueError):
|
|
156
|
+
continue
|
|
157
|
+
if (lo_year is None or year >= lo_year) and (hi_year is None or year <= hi_year):
|
|
158
|
+
part = ydir / "part.parquet"
|
|
159
|
+
if part.exists():
|
|
160
|
+
files.append(str(part))
|
|
161
|
+
return files
|
|
162
|
+
|
|
163
|
+
def _window_files(self, tf: str, symbol: str, start: pd.Timestamp, end: pd.Timestamp) -> List[str]:
|
|
164
|
+
"""The partition files for ``symbol`` overlapping ``[start.year, end.year]``."""
|
|
165
|
+
return self._year_files(self._symbol_dir(tf, symbol), start.year, end.year)
|
|
166
|
+
|
|
167
|
+
def partition_paths(
|
|
168
|
+
self,
|
|
169
|
+
universe: Sequence[str],
|
|
170
|
+
timeframe: TimeframeLike = "1Day",
|
|
171
|
+
as_of: Optional[datetime] = None,
|
|
172
|
+
lookback_days: int = 365,
|
|
173
|
+
) -> List[str]:
|
|
174
|
+
"""The Parquet partition files backing ``universe`` (existing partitions only).
|
|
175
|
+
|
|
176
|
+
The file list a DuckDB/Polars set-based query (:func:`quantred.data.compute.sql_query`)
|
|
177
|
+
or :meth:`scan_lazy` reads over. With ``as_of`` given the list is pruned to the
|
|
178
|
+
``year=`` partitions overlapping ``(as_of - lookback, as_of]`` (file-level
|
|
179
|
+
date pruning); without it, every year partition for the symbols is returned.
|
|
180
|
+
"""
|
|
181
|
+
tf = str(Timeframe.parse(timeframe) if isinstance(timeframe, str) else timeframe)
|
|
182
|
+
if as_of is None:
|
|
183
|
+
lo_year = hi_year = None
|
|
184
|
+
else:
|
|
185
|
+
start, end = self._window(as_of, lookback_days)
|
|
186
|
+
lo_year, hi_year = start.year, end.year
|
|
187
|
+
paths: List[str] = []
|
|
188
|
+
for symbol in dict.fromkeys(universe):
|
|
189
|
+
paths.extend(self._year_files(self._symbol_dir(tf, symbol), lo_year, hi_year))
|
|
190
|
+
return paths
|
|
191
|
+
|
|
192
|
+
def scan_lazy(
|
|
193
|
+
self,
|
|
194
|
+
universe: List[str],
|
|
195
|
+
timeframe: TimeframeLike,
|
|
196
|
+
as_of: datetime,
|
|
197
|
+
lookback_days: int = 365,
|
|
198
|
+
columns: Optional[Sequence[str]] = None,
|
|
199
|
+
) -> "pl.LazyFrame":
|
|
200
|
+
"""Return a **lazy** long-format panel for ``universe`` over ``(as_of - lookback, as_of]``.
|
|
201
|
+
|
|
202
|
+
The compute-tier counterpart to :meth:`scan`: instead of materializing
|
|
203
|
+
per-symbol pandas frames, it returns a Polars :class:`~polars.LazyFrame` over
|
|
204
|
+
the Parquet partitions with the ``as_of`` window pushed into the reader as a
|
|
205
|
+
predicate (rows after ``as_of`` are physically never read) and an optional
|
|
206
|
+
column projection pushed down too. ``as_of`` may be naive (treated as UTC) or
|
|
207
|
+
tz-aware; the window matches the eager :meth:`scan` exactly (strict ``>`` on the
|
|
208
|
+
lower bound, ``<=`` on ``as_of``). Nothing executes until a terminal collect
|
|
209
|
+
(:func:`quantred.data.edges.collect_streaming`). The leaf scan + pushdown stream; a
|
|
210
|
+
downstream ``over(...)``/sort buffers (see :mod:`quantred.data.compute` on what is
|
|
211
|
+
genuinely bounded-memory). Requires the ``store`` extra (``polars``).
|
|
212
|
+
|
|
213
|
+
An empty universe yields an empty but **schema-carrying** LazyFrame, so a
|
|
214
|
+
window helper composed onto it resolves its source column and returns an empty
|
|
215
|
+
frame rather than raising (the lazy analogue of :meth:`scan` returning ``{}``).
|
|
216
|
+
"""
|
|
217
|
+
import polars as pl
|
|
218
|
+
|
|
219
|
+
schema = {
|
|
220
|
+
"ts": pl.Datetime("us", "UTC"),
|
|
221
|
+
"symbol": pl.Utf8,
|
|
222
|
+
"open": pl.Float64,
|
|
223
|
+
"high": pl.Float64,
|
|
224
|
+
"low": pl.Float64,
|
|
225
|
+
"close": pl.Float64,
|
|
226
|
+
"volume": pl.Float64,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# File-level date pruning: only year partitions overlapping the window.
|
|
230
|
+
paths = self.partition_paths(universe, timeframe, as_of=as_of, lookback_days=lookback_days)
|
|
231
|
+
if not paths:
|
|
232
|
+
empty = pl.DataFrame(schema=schema).lazy()
|
|
233
|
+
if columns is not None:
|
|
234
|
+
empty = empty.select(list(dict.fromkeys(["ts", "symbol", *columns])))
|
|
235
|
+
return empty
|
|
236
|
+
|
|
237
|
+
start, end = self._window(as_of, lookback_days)
|
|
238
|
+
# hive_partitioning=false: `symbol` is a real column in each file; don't also
|
|
239
|
+
# synthesize it from the `symbol=…` directory (that collides).
|
|
240
|
+
lf = pl.scan_parquet(paths, hive_partitioning=False)
|
|
241
|
+
# Predicate pushdown: the window (in particular the as_of upper bound) is
|
|
242
|
+
# pushed into the Parquet SCAN, so post-as_of rows are never read.
|
|
243
|
+
lf = lf.filter((pl.col("ts") > pl.lit(start)) & (pl.col("ts") <= pl.lit(end)))
|
|
244
|
+
if columns is not None:
|
|
245
|
+
keep = list(dict.fromkeys(["ts", "symbol", *columns]))
|
|
246
|
+
lf = lf.select(keep)
|
|
247
|
+
return lf
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------ #
|
|
250
|
+
# The pandas edge (the only sanctioned crossings)
|
|
251
|
+
# ------------------------------------------------------------------ #
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _to_records(symbol: str, frame: pd.DataFrame) -> pd.DataFrame:
|
|
254
|
+
"""OHLCV frame (ts index) → a flat, schema-typed records frame for Parquet."""
|
|
255
|
+
records = frame.reset_index()
|
|
256
|
+
records = records.rename(columns={records.columns[0]: "ts"})
|
|
257
|
+
ts = pd.to_datetime(records["ts"], utc=True)
|
|
258
|
+
records["ts"] = ts
|
|
259
|
+
records["symbol"] = symbol
|
|
260
|
+
for col in ("open", "high", "low", "close", "volume"):
|
|
261
|
+
records[col] = records[col].astype("float64")
|
|
262
|
+
return records[BAR_COLUMNS]
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _from_records(records: pd.DataFrame) -> pd.DataFrame:
|
|
266
|
+
"""Records frame → an OHLCV frame indexed by a sorted UTC timestamp."""
|
|
267
|
+
frame = records.sort_values("ts").set_index("ts")
|
|
268
|
+
frame.index = pd.DatetimeIndex(frame.index, name="timestamp")
|
|
269
|
+
return frame[["open", "high", "low", "close", "volume"]]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""What `quantred demo` runs on, and what a bare install falls back to.
|
|
2
|
+
|
|
3
|
+
Not examples to copy — that is ``example``, which
|
|
4
|
+
``quantred init --example-pack`` hands you as a repository you own. These exist for
|
|
5
|
+
three narrower jobs:
|
|
6
|
+
|
|
7
|
+
* ``quantred demo`` needs a strategy to put through the pipeline, offline and with no
|
|
8
|
+
keys, and it cannot depend on anything the user has not installed yet.
|
|
9
|
+
* ``--strategy`` and ``--scanner`` need a default that resolves on a bare install, or
|
|
10
|
+
every command fails at lookup rather than saying something useful.
|
|
11
|
+
* There should be one smallest complete strategy in the tree to read.
|
|
12
|
+
|
|
13
|
+
They are registered through the same two entry-point groups a private pack uses, and
|
|
14
|
+
declared in this project's own ``pyproject.toml``. That is deliberate: the discovery
|
|
15
|
+
path the whole private-strategy feature rests on is then exercised by every install
|
|
16
|
+
rather than only by tests. The registry names them directly as well, because
|
|
17
|
+
enumeration order across distributions is undefined and these two names are reserved -
|
|
18
|
+
a pack cannot take them, and which class answers to them is not left to chance.
|
|
19
|
+
|
|
20
|
+
``demo_trend`` and ``demo_volume`` are named for what they are for. Neither is an edge,
|
|
21
|
+
and neither is where your work goes.
|
|
22
|
+
"""
|