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/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
+ """