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
@@ -0,0 +1,11 @@
1
+ """Transaction costs: price the cost of trading so research metrics are net, not gross.
2
+
3
+ A parametric model (commission + half-spread + square-root market impact) charged on
4
+ every simulated fill, and an alpha haircut + linear turnover term for the optimizer.
5
+ Research-clock only - the live path gets real fills from the broker.
6
+ """
7
+
8
+ from quantred.costs.base import CostModel, Trade, TradeCost
9
+ from quantred.costs.parametric import ParametricCostModel
10
+
11
+ __all__ = ["CostModel", "Trade", "TradeCost", "ParametricCostModel"]
quantred/costs/base.py ADDED
@@ -0,0 +1,126 @@
1
+ """Transaction-cost model core - what it costs to change a position.
2
+
3
+ Every Sharpe and equity curve the backtester produces is *gross* until a cost model
4
+ charges trading. Gross results are the most reliable way to fool yourself: the
5
+ strategies that look best in-sample are very often the highest-turnover ones, which
6
+ are exactly the ones costs destroy. Active management treats cost as first-class -
7
+ it caps the transfer coefficient in ``IR ≈ TC·IC·√BR``.
8
+
9
+ This module owns the shared types: :class:`Trade` (what is being traded, plus the
10
+ liquidity context), :class:`TradeCost` (the commission / spread / impact breakdown),
11
+ and the :class:`CostModel` interface. The concrete model lives in
12
+ :mod:`quantred.costs.parametric`.
13
+
14
+ **A pure pricing helper, and both clocks may use it.** It said "research-clock only:
15
+ the live path gets real fills from the broker" long after that stopped being true -
16
+ ``execution.live_trader._cost_estimate`` imports it and calls ``cost()`` on the order
17
+ path, deliberately, so the modelled number in a live report is comparable with the one
18
+ the backtest was judged on. Nothing is broken by that: ``costs/`` is not one of the
19
+ packages the trade-clock rule names, and what lives here is arithmetic over its
20
+ arguments - no model, no optimizer, no database, no network. But a stale boundary
21
+ comment is how the next person justifies a real violation, so the claim is corrected
22
+ rather than left to be discovered.
23
+
24
+ What must stay true is the reason the boundary was drawn: what this computes is a
25
+ *prediction*, and a broker fee is an *observation*. The live path records them
26
+ separately and must never merge them.
27
+ """
28
+
29
+ from abc import ABC, abstractmethod
30
+ from dataclasses import dataclass
31
+ from typing import Optional
32
+
33
+
34
+ @dataclass
35
+ class Trade:
36
+ """A trade to be priced, with the trailing liquidity context it's priced against."""
37
+
38
+ symbol: str
39
+ shares: float # signed; cost is charged on the absolute size
40
+ price: float
41
+ adv: float # trailing average daily volume (shares), as-of the trade
42
+ daily_vol: float # trailing daily return volatility (fraction), as-of the trade
43
+ spread: float = 0.0005 # quoted spread (fraction, e.g. 0.0005 = 5 bps)
44
+
45
+ @property
46
+ def notional(self) -> float:
47
+ return abs(self.shares) * self.price
48
+
49
+ @property
50
+ def participation(self) -> float:
51
+ """Fraction of a day's volume this trade demands (`|q| / ADV`)."""
52
+ return abs(self.shares) / self.adv if self.adv > 0 else float("inf")
53
+
54
+
55
+ @dataclass
56
+ class TradeCost:
57
+ """The decomposed dollar cost of a trade (commission + spread + impact)."""
58
+
59
+ commission: float
60
+ spread_cost: float
61
+ impact_cost: float
62
+ participation: float
63
+ capped: bool # participation exceeded the realistic cap → fill is optimistic
64
+
65
+ @property
66
+ def total(self) -> float:
67
+ return self.commission + self.spread_cost + self.impact_cost
68
+
69
+
70
+ class CostModel(ABC):
71
+ """Prices the cost of changing a position."""
72
+
73
+ @abstractmethod
74
+ def cost(self, trade: Trade) -> TradeCost:
75
+ """Return the decomposed dollar cost of ``trade``."""
76
+
77
+ def cost_rate(self, trade: Trade) -> float:
78
+ """One-way cost as a fraction of traded notional."""
79
+ notional = trade.notional
80
+ return self.cost(trade).total / notional if notional > 0 else 0.0
81
+
82
+ def turnover_cost_rate(self, spread: float = None) -> float:
83
+ """Linear (size-independent) one-way cost per unit of traded notional.
84
+
85
+ The size-independent part of a trade's cost — the optimizer's L1 turnover
86
+ coefficient (the cost-aware objective). A model with no linear cost returns 0
87
+ (the default);
88
+ :class:`~quantred.costs.parametric.ParametricCostModel` returns commission + s/2.
89
+ """
90
+ return 0.0
91
+
92
+ def impact_coefficient(self, daily_vol: float, adv_dollar: float, capital: float) -> float:
93
+ """√-impact coefficient ``k`` for the optimizer's ``Σ kᵢ·|Δwᵢ|^{3/2}`` term.
94
+
95
+ The realistic square-root impact is convex in size but not quadratic, so it
96
+ enters the portfolio optimizer's objective as a conic term rather than the
97
+ risk quadratic.
98
+ ``k`` is *per unit of capital, per rebalance*: the impact cost as a fraction of
99
+ capital of trading ``|Δwᵢ|`` (a fraction of the ``capital`` book) is
100
+ ``k·|Δwᵢ|^{3/2}``. A model with no impact returns 0 (the default).
101
+ """
102
+ return 0.0
103
+
104
+ def carry_cost(self, notional: float, is_short: bool, holding_years: float) -> float:
105
+ """Financing cost of *holding* a position (borrow on shorts). Default: none."""
106
+ return 0.0
107
+
108
+ def borrow_rate(self, override: Optional[float] = None) -> float:
109
+ """Annualized borrow rate (fraction of notional per year) charged on a short
110
+ holding - the portfolio optimizer's per-name short-side cost tilt,
111
+ threaded the same way :meth:`turnover_cost_rate` threads the per-name spread.
112
+ ``override`` is a per-name rate from :class:`~quantred.portfolio.optimizer.CostInputs`
113
+ (a locate-desk quote or a manual hard-to-borrow override); a model with no
114
+ borrow cost returns 0 regardless of ``override``.
115
+ """
116
+ return 0.0
117
+
118
+ def annual_cost_rate(self, trade: Trade, holding_period_years: float) -> float:
119
+ """Round-trip cost amortized over the holding period - the alpha haircut rate.
120
+
121
+ A name held for a month pays its round-trip cost ~12x a year; a +4%/yr alpha
122
+ with a 2% round-trip held one month is deeply negative, not a +4% opportunity.
123
+ """
124
+ if holding_period_years <= 0:
125
+ return float("inf")
126
+ return 2.0 * self.cost_rate(trade) / holding_period_years
@@ -0,0 +1,131 @@
1
+ """Parametric cost model: commission + half-spread + square-root market impact.
2
+
3
+ For a trade of ``q`` shares at price ``p`` in a name with average daily volume
4
+ ``ADV`` and quoted spread ``s``:
5
+
6
+ cost($) = commission + (s/2)·|q|·p + impact_rate·|q|·p
7
+
8
+ with the empirically robust **square-root law** for impact (Almgren et al.):
9
+
10
+ impact_rate = η · σ_daily · √(|q| / ADV)
11
+
12
+ This is concave in size *per share* (√) but convex in *total* cost
13
+ (``|q|·impact_rate ∝ |q|^{3/2}``) - the property that makes an optimizer prefer to
14
+ spread a target across names. A linear impact fallback is offered for a fully
15
+ convex-quadratic optimizer formulation. Defaults are calibrated to liquid US
16
+ equities and are all overridable.
17
+ """
18
+
19
+ import math
20
+ from typing import Optional
21
+
22
+ from quantred.costs.base import CostModel, Trade, TradeCost
23
+
24
+
25
+ class ParametricCostModel(CostModel):
26
+ """Commission + half-spread + (square-root or linear) market impact."""
27
+
28
+ def __init__(
29
+ self,
30
+ commission_bps: float = 1.0,
31
+ default_spread_bps: float = 5.0,
32
+ impact_eta: float = 0.3,
33
+ participation_cap: float = 0.10,
34
+ annual_borrow_bps: float = 50.0,
35
+ linear_impact: bool = False,
36
+ ):
37
+ self.commission_rate = commission_bps / 1e4
38
+ self.default_spread = default_spread_bps / 1e4
39
+ self.impact_eta = impact_eta
40
+ self.participation_cap = participation_cap
41
+ self.annual_borrow_rate = annual_borrow_bps / 1e4 # financing cost to hold a short
42
+ self.linear_impact = linear_impact # linear participation (convex-quadratic) vs √-law
43
+
44
+ def cost(self, trade: Trade) -> TradeCost:
45
+ notional = trade.notional
46
+ if notional <= 0:
47
+ return TradeCost(0.0, 0.0, 0.0, 0.0, capped=False)
48
+
49
+ spread = trade.spread if trade.spread is not None else self.default_spread
50
+ participation = trade.participation
51
+
52
+ commission = self.commission_rate * notional
53
+ spread_cost = (spread / 2.0) * notional
54
+ if math.isinf(participation):
55
+ impact_rate = 0.0 # no ADV info → can't size impact; commission+spread only
56
+ elif self.linear_impact:
57
+ impact_rate = self.impact_eta * trade.daily_vol * participation
58
+ else:
59
+ impact_rate = self.impact_eta * trade.daily_vol * math.sqrt(participation)
60
+ impact_cost = impact_rate * notional
61
+
62
+ return TradeCost(
63
+ commission=commission,
64
+ spread_cost=spread_cost,
65
+ impact_cost=impact_cost,
66
+ participation=participation,
67
+ capped=participation > self.participation_cap,
68
+ )
69
+
70
+ def carry_cost(self, notional: float, is_short: bool, holding_years: float) -> float:
71
+ """Financing cost of *holding* a position - borrow on shorts, accrued over time.
72
+
73
+ Long positions have no borrow cost here (margin financing is a separate, later
74
+ concern); a short pays ``borrow_rate · notional · holding_years``. This is what
75
+ stops a short-heavy strategy from being silently flattered.
76
+ """
77
+ if not is_short or holding_years <= 0:
78
+ return 0.0
79
+ return self.annual_borrow_rate * abs(notional) * holding_years
80
+
81
+ def borrow_rate(self, override: float = None) -> float:
82
+ """Annualized borrow rate - the flat ``annual_borrow_bps`` default, or a
83
+ per-name override (a locate-desk quote) when the caller has one.
84
+ """
85
+ return self.annual_borrow_rate if override is None else override
86
+
87
+ def turnover_cost_rate(self, spread: float = None) -> float:
88
+ """Linear cost per unit of turnover notional - the optimizer's L1 cost term.
89
+
90
+ The size-independent part (commission + half-spread); impact is omitted here
91
+ because it's non-linear in trade size (it belongs in the √-law accounting).
92
+ """
93
+ s = self.default_spread if spread is None else spread
94
+ return self.commission_rate + s / 2.0
95
+
96
+ def impact_coefficient(self, daily_vol: float, adv_dollar: float, capital: float) -> float:
97
+ """√-impact coefficient ``k`` (per unit capital) for the optimizer's conic term.
98
+
99
+ The √-law impact rate is ``η·σ·√(participation)`` with ``participation =
100
+ |q|/ADV``. Trading ``|Δw|`` of a ``capital``-dollar book is ``q = |Δw|·capital/p``
101
+ shares, so ``participation = |Δw|·capital / (p·ADV) = |Δw|·capital / ADV$``. The
102
+ impact cost as a *fraction of capital* is then ``η·σ·√(capital/ADV$)·|Δw|^{3/2}``,
103
+ so ``k = η·σ·√(capital/ADV$)``. Zero when ADV is unavailable (mirrors
104
+ :meth:`cost`, which charges no impact without volume) or in linear-impact mode
105
+ (that fallback is quadratic-in-size, not conic, and isn't fed to the optimizer).
106
+ """
107
+ # `not (x > 0)` (rather than `x <= 0`) also rejects NaN, so a bad ADV/vol input
108
+ # drops the impact term instead of poisoning the solve with a NaN coefficient.
109
+ if self.linear_impact or not (adv_dollar > 0) or not (capital > 0) or not (daily_vol > 0):
110
+ return 0.0
111
+ return self.impact_eta * daily_vol * math.sqrt(capital / adv_dollar)
112
+
113
+
114
+ def cost_curvature(k_imp: float, typical_trade: float) -> Optional[float]:
115
+ """Local quadratic curvature ``c2 = d2cost/dDeltaw2`` of the √-impact term at a
116
+ representative trade size: ``cost(Δw) = k·|Δw|^{3/2}`` (impact
117
+ only - the linear term is a kink, not curvature, at any Δw != 0), so
118
+ ``d2/dΔw2 = (3/4)·k·|Δw|^{-1/2}``. This is the curvature a quadratic-cost
119
+ closed form needs to derive the trading rate κ
120
+ (:func:`quantred.portfolio.policy.derive_kappa`); our cost is linear+3/2-power, so
121
+ this is a *calibrated-at-realistic-size* approximation, not exact -
122
+ validated empirically by the net-of-cost A/B, not asserted.
123
+
124
+ Returns ``None`` (curvature undefined) when there is no impact term
125
+ (``k_imp <= 0``, e.g. no capital/ADV so the solve is linear-only) or the trade
126
+ size is non-positive - the signal for :func:`~quantred.portfolio.policy.derive_kappa`
127
+ to fall back to band-only behavior rather than divide by (near) zero.
128
+ """
129
+ if not (k_imp > 0) or not (typical_trade > 0):
130
+ return None
131
+ return 0.75 * k_imp / math.sqrt(typical_trade)
@@ -0,0 +1,29 @@
1
+ """Cross-sectional data substrate: scan bars point-in-time, assemble a feature panel.
2
+
3
+ The single place data is gathered for cross-sectional research (alphas, risk,
4
+ portfolio construction, information analysis): :func:`~quantred.data.scan.BarSource`
5
+ scans a universe as of a timestamp (leakage-safe), and a :class:`~quantred.data.panel.FeaturePanel`
6
+ holds every name's features in one table that producers fill and consumers read.
7
+ """
8
+
9
+ from quantred.data.features import Scorer, add_factor_exposure_features, add_risk_features, add_score_feature
10
+ from quantred.data.panel import FeaturePanel
11
+ from quantred.data.scan import BarSource, ClientBarSource, slice_to_as_of
12
+ from quantred.data.store import BAR_COLUMNS, ParquetBarStore
13
+
14
+ # Lazy out-of-core compute lives in quantred.data.compute / quantred.data.edges and
15
+ # is imported on demand — both need the optional ``store`` extra (polars/duckdb), so
16
+ # they are intentionally not pulled into the base-install import path here.
17
+
18
+ __all__ = [
19
+ "BarSource",
20
+ "ClientBarSource",
21
+ "slice_to_as_of",
22
+ "FeaturePanel",
23
+ "Scorer",
24
+ "add_factor_exposure_features",
25
+ "add_risk_features",
26
+ "add_score_feature",
27
+ "ParquetBarStore",
28
+ "BAR_COLUMNS",
29
+ ]
@@ -0,0 +1,332 @@
1
+ """Lazy, out-of-core compute over the bar panel (Polars · DuckDB).
2
+
3
+ The *storage* half of out-of-core (a Parquet/Arrow ``BarSource`` behind ``scan()``)
4
+ lives in the store module. This module is the *compute* half: the hot, panel-wide
5
+ operations expressed as a **lazy** plan, rather than eagerly materializing a ``T×N``
6
+ pandas panel into RAM.
7
+
8
+ Two engines, sharing Arrow so it isn't either/or:
9
+
10
+ - **Polars ``LazyFrame``** for dataframe-style transforms — rolling-window indicators
11
+ become window expressions ``.over("symbol", order_by="ts")``, and the
12
+ cross-sectional z-score / winsorize / rank become ``.over("ts")`` expressions.
13
+ Nothing materializes until a terminal :func:`~quantred.data.edges.collect_streaming`.
14
+ - **DuckDB SQL** (:func:`sql_query`) for set-based work over the Parquet store
15
+ (aggregations, joins) — its out-of-core engine streams the scan too.
16
+
17
+ Three disciplines this module keeps:
18
+
19
+ 1. **Pushdown survives the plan.** A lazy scan reads only the columns/row-ranges it
20
+ needs and the ``as_of`` predicate is pushed into the Parquet reader — never a
21
+ post-materialize filter (see :meth:`ParquetBarStore.scan_lazy`).
22
+ 2. **Determinism under multithreading.** Polars/DuckDB parallelize; float reduction
23
+ order can vary. The cross-sectional reductions here are order-invariant
24
+ (mean/std/quantile), and :data:`CANONICAL_SORT` pins a stable output order so
25
+ repeated runs are byte-identical.
26
+ 3. **The pandas edge stays narrow.** Every crossing back to pandas routes through
27
+ :mod:`quantred.data.edges`; this module returns Polars frames / numpy, never pandas.
28
+
29
+ **A note on what is genuinely bounded-memory.** The leaf Parquet scan (with its
30
+ pushed-down predicate/projection) streams, and :func:`streaming_covariance` is bounded
31
+ by the universe size, not by history length. But in the current Polars engine a
32
+ ``.over(...)`` window groupby and a top-level :func:`sort_canonical` are *buffering*
33
+ operators — they materialize their input — so a pipeline that ends on a cross-sectional
34
+ ``over("ts")`` plus a canonical sort has peak memory ~ the panel it sorts, not O(chunk).
35
+ The honest out-of-core wins today are the streaming scan, the as-of pushdown, and the
36
+ streaming covariance accumulator; the set-based DuckDB path (which can spill) is the
37
+ route to a genuinely bounded cross-sectional reduction. The window *math* is correct
38
+ either way; only the memory profile of the buffering stages is not O(1) in history.
39
+
40
+ The legacy pandas implementations (:mod:`quantred.indicators.indicators`,
41
+ :mod:`quantred.alphas.refine`, :mod:`quantred.risk.sample`) are kept as the equivalence
42
+ oracle — the tests prove these lazy ports match them within tolerance.
43
+
44
+ Requires the ``store`` extra (``polars``; ``duckdb`` for :func:`sql_query`).
45
+ """
46
+
47
+ from typing import TYPE_CHECKING, Iterable, List, Optional, Sequence, Tuple
48
+
49
+ import numpy as np
50
+
51
+ if TYPE_CHECKING: # pragma: no cover - typing only
52
+ import polars as pl
53
+
54
+ #: Column names of the long-format bar panel (matches the Parquet store schema).
55
+ TS = "ts"
56
+ SYMBOL = "symbol"
57
+
58
+ #: Canonical output ordering. Pinning an explicit sort makes a multithreaded collect
59
+ #: byte-identical run to run (determinism).
60
+ CANONICAL_SORT = [TS, SYMBOL]
61
+
62
+
63
+ def sort_canonical(lf: "pl.LazyFrame") -> "pl.LazyFrame":
64
+ """Pin the deterministic ``(ts, symbol)`` output order on a lazy plan."""
65
+ return lf.sort(CANONICAL_SORT)
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # Time-series indicators — Polars window expressions, partitioned per symbol.
70
+ # Pandas (quantred/indicators) stays the per-symbol leaf and the equivalence oracle.
71
+ # --------------------------------------------------------------------------- #
72
+ def _ordered_over(expr: "pl.Expr", by: str, order: str = TS) -> "pl.Expr":
73
+ """A window expression evaluated per ``by`` group, **sorted by ``order`` within it**.
74
+
75
+ A rolling/lagged indicator is meaningless unless the window sees rows in time
76
+ order, and the on-disk/scan order is not guaranteed to be ts-ascending (multiple
77
+ row groups, concatenated files, an unsorted write). Passing ``order_by`` makes the
78
+ window sort *inside* the group, so the result is independent of physical row order
79
+ — matching the eager :meth:`ParquetBarStore.scan` path, which sorts each symbol by
80
+ ts. (A post-hoc :func:`sort_canonical` cannot fix this: it reorders the finished
81
+ output, not the sequence the window was computed over.)
82
+ """
83
+ return expr.over(by, order_by=order)
84
+
85
+
86
+ def with_sma(
87
+ lf: "pl.LazyFrame", period: int, *, src: str = "close", out: str = "sma", by: str = SYMBOL
88
+ ) -> "pl.LazyFrame":
89
+ """Add a simple moving average column, per symbol (matches ``calculate_sma``).
90
+
91
+ Polars ``rolling_mean`` defaults ``min_periods == window_size``, so the head is
92
+ null until the window fills — exactly pandas' ``rolling(period).mean()``. The
93
+ window is ts-ordered within each symbol (see :func:`_ordered_over`).
94
+ """
95
+ import polars as pl
96
+
97
+ return lf.with_columns(_ordered_over(pl.col(src).rolling_mean(window_size=period), by).alias(out))
98
+
99
+
100
+ def with_ema(
101
+ lf: "pl.LazyFrame", period: int, *, src: str = "close", out: str = "ema", by: str = SYMBOL
102
+ ) -> "pl.LazyFrame":
103
+ """Add an exponential moving average column, per symbol (matches ``calculate_ema``).
104
+
105
+ Uses ``adjust=False`` to match pandas' ``ewm(span=period, adjust=False).mean()``
106
+ (recursive form seeded on the first observation), ts-ordered within each symbol.
107
+
108
+ Equivalence holds for a gap-free source. An *interior* null/NaN between two valid
109
+ bars diverges from pandas: pandas (``ignore_na=False``) decays the weight across the
110
+ gap, Polars re-weights as if the missing bar were absent. A clean bar series has no
111
+ interior gaps (the only nulls are the leading warm-up, which agree); feed
112
+ gap-filled input if a source can drop interior bars.
113
+ """
114
+ import polars as pl
115
+
116
+ return lf.with_columns(_ordered_over(pl.col(src).ewm_mean(span=period, adjust=False), by).alias(out))
117
+
118
+
119
+ def with_returns(
120
+ lf: "pl.LazyFrame", *, src: str = "close", out: str = "ret", by: str = SYMBOL
121
+ ) -> "pl.LazyFrame":
122
+ """Add a one-bar simple return column, per symbol (matches ``close.pct_change()``).
123
+
124
+ ts-ordered within each symbol. Note ``pct_change`` off a zero/again-zero price is a
125
+ genuine NaN/inf (``0/0``, ``x/0``) just as in pandas; the cross-sectional helpers
126
+ below treat such non-finite values as missing.
127
+ """
128
+ import polars as pl
129
+
130
+ return lf.with_columns(_ordered_over(pl.col(src).pct_change(), by).alias(out))
131
+
132
+
133
+ # --------------------------------------------------------------------------- #
134
+ # Cross-sectional refinement — Polars ``.over("ts")`` expressions on the panel.
135
+ # quantred/alphas/refine.py stays the equivalence oracle.
136
+ #
137
+ # Missing-data contract: the pandas oracle (refine.py) runs on ``.dropna()`` — it
138
+ # skips non-finite names and standardizes/ranks the rest. Polars reductions skip
139
+ # *null* but PROPAGATE NaN/inf, which would poison the whole cross-section (one NaN
140
+ # score → every name's z collapses to 0; a NaN gets handed the top rank). So each
141
+ # helper first maps non-finite to null via ``_finite`` — then a single contaminated
142
+ # name no longer destroys the rest of the cross-section, matching the oracle.
143
+ # --------------------------------------------------------------------------- #
144
+ def _finite(src: str) -> "pl.Expr":
145
+ """``src`` with non-finite values (NaN/±inf) mapped to null, so reductions skip them."""
146
+ import polars as pl
147
+
148
+ col = pl.col(src)
149
+ return pl.when(col.is_finite()).then(col).otherwise(None)
150
+
151
+
152
+ def cross_sectional_zscore(
153
+ lf: "pl.LazyFrame", *, src: str = "score", out: str = "z", by: str = TS
154
+ ) -> "pl.LazyFrame":
155
+ """Cross-sectional z-score ``(x - mean) / std`` per timestamp (matches ``refine.zscore``).
156
+
157
+ Uses the population std (``ddof=0``) so the cross-section has unit dispersion.
158
+ Non-finite names are skipped (null out) exactly as the oracle's ``dropna``; a
159
+ degenerate cross-section (zero std across the finite names) maps to all-zeros — a
160
+ universe with no spread expresses no view, the same contract as the pandas oracle.
161
+ """
162
+ import polars as pl
163
+
164
+ x = _finite(src)
165
+ mean = x.mean().over(by)
166
+ std = x.std(ddof=0).over(by)
167
+ z = (
168
+ pl.when(x.is_null()) # a missing/non-finite name stays missing (oracle: NaN)
169
+ .then(None)
170
+ .when((std == 0) | std.is_null()) # no spread among finite names → no view
171
+ .then(pl.lit(0.0))
172
+ .otherwise((x - mean) / std)
173
+ )
174
+ return lf.with_columns(z.alias(out))
175
+
176
+
177
+ def cross_sectional_winsorize(
178
+ lf: "pl.LazyFrame",
179
+ *,
180
+ src: str = "score",
181
+ out: Optional[str] = None,
182
+ lower: float = 0.025,
183
+ upper: float = 0.975,
184
+ by: str = TS,
185
+ ) -> "pl.LazyFrame":
186
+ """Clip ``src`` to its ``[lower, upper]`` cross-sectional quantiles (matches ``refine.winsorize``).
187
+
188
+ Quantiles use linear interpolation to match pandas' ``Series.quantile`` default and
189
+ are taken over the finite names only (non-finite stay missing). Writes back into
190
+ ``src`` unless ``out`` is given.
191
+ """
192
+
193
+ out = out or src
194
+ x = _finite(src)
195
+ lo = x.quantile(lower, interpolation="linear").over(by)
196
+ hi = x.quantile(upper, interpolation="linear").over(by)
197
+ return lf.with_columns(x.clip(lower_bound=lo, upper_bound=hi).alias(out))
198
+
199
+
200
+ def cross_sectional_rank(
201
+ lf: "pl.LazyFrame", *, src: str = "score", out: str = "rank", by: str = TS
202
+ ) -> "pl.LazyFrame":
203
+ """Add a cross-sectional rank per timestamp (1 = smallest), average ties.
204
+
205
+ ``method="average"`` matches pandas' ``Series.rank()`` default. Non-finite names are
206
+ null (Polars ranks null as null, like pandas), so a missing score is never handed a
207
+ tradable rank.
208
+ """
209
+
210
+ return lf.with_columns(_finite(src).rank(method="average").over(by).alias(out))
211
+
212
+
213
+ def cross_sectional_demean(
214
+ lf: "pl.LazyFrame", *, src: str = "score", out: str = "z", by: str = TS
215
+ ) -> "pl.LazyFrame":
216
+ """Subtract the cross-sectional mean per timestamp (matches ``refine.demean``).
217
+
218
+ Mean is taken over the finite names; non-finite names stay missing.
219
+ """
220
+
221
+ x = _finite(src)
222
+ return lf.with_columns((x - x.mean().over(by)).alias(out))
223
+
224
+
225
+ # --------------------------------------------------------------------------- #
226
+ # Streaming covariance — accumulate cross-products by streaming the return panel
227
+ # instead of materializing the full T×N matrix. quantred/risk/sample.SampleCovariance
228
+ # is the equivalence oracle (this reproduces its population MLE estimate).
229
+ # --------------------------------------------------------------------------- #
230
+ def streaming_covariance(
231
+ return_chunks: Iterable[np.ndarray],
232
+ ) -> Tuple[np.ndarray, int]:
233
+ """Per-bar covariance accumulated over chunks of an aligned return panel.
234
+
235
+ Each chunk is a ``rows × N`` block of an already-aligned (complete-case) return
236
+ panel — the same panel :func:`quantred.risk.base.build_return_panel` produces, but fed
237
+ in row-chunks so no more than one chunk plus the ``N×N`` accumulator is ever in
238
+ memory. Memory is bounded by the **universe size**, not the length of history —
239
+ the point of an out-of-core covariance.
240
+
241
+ Accumulates the uncentered sum ``s = Σ xₜ`` and second-moment ``S₂ = Σ xₜxₜᵀ``,
242
+ then returns the population covariance ``Σ = S₂/n − (s/n)(s/n)ᵀ``. The result
243
+ matches the single-pass estimate to machine epsilon regardless of how the rows are
244
+ split (a tested invariant; not byte-identical, since float summation order varies
245
+ with chunking). The uncentered form is *algebraically* identical to centering first,
246
+ and *numerically* matches :class:`~quantred.risk.sample.SampleCovariance` to machine
247
+ epsilon for the inputs it is contracted on — **return-scale, near-zero-mean** rows
248
+ (what :func:`quantred.risk.base.build_return_panel` produces from ``pct_change``). It is
249
+ NOT for price levels: when the mean dwarfs the spread (|mean| ≫ std), ``S₂/n`` and
250
+ ``(s/n)(s/n)ᵀ`` are two large near-equal numbers and their difference loses
251
+ precision (catastrophic cancellation). Feed returns, not levels.
252
+
253
+ Returns ``(Σ_per_bar, n_observations)``; with fewer than two observations Σ is the
254
+ zero matrix (mirroring the oracle's degenerate-case contract).
255
+ """
256
+ s: Optional[np.ndarray] = None
257
+ s2: Optional[np.ndarray] = None
258
+ count = 0
259
+ n_cols = 0
260
+
261
+ for chunk in return_chunks:
262
+ x = np.asarray(chunk, dtype=float)
263
+ if x.ndim != 2 or x.shape[0] == 0:
264
+ continue
265
+ if s is None:
266
+ n_cols = x.shape[1]
267
+ s = np.zeros(n_cols)
268
+ s2 = np.zeros((n_cols, n_cols))
269
+ elif x.shape[1] != n_cols:
270
+ raise ValueError(f"chunk has {x.shape[1]} columns, expected {n_cols}")
271
+ s += x.sum(axis=0)
272
+ s2 += x.T @ x
273
+ count += x.shape[0]
274
+
275
+ if count < 2 or s is None:
276
+ return np.zeros((n_cols, n_cols)), count
277
+ mean = s / count
278
+ cov = s2 / count - np.outer(mean, mean)
279
+ return cov, count
280
+
281
+
282
+ def iter_row_chunks(matrix: np.ndarray, chunk_rows: int) -> Iterable[np.ndarray]:
283
+ """Yield row-blocks of ``matrix`` of at most ``chunk_rows`` rows.
284
+
285
+ The bridge from a materialized return panel to :func:`streaming_covariance`: lets
286
+ a caller feed an in-memory panel in bounded slices, or a streaming reader hand
287
+ chunks straight through.
288
+ """
289
+ if chunk_rows < 1:
290
+ raise ValueError("chunk_rows must be >= 1")
291
+ for start in range(0, len(matrix), chunk_rows):
292
+ yield matrix[start : start + chunk_rows]
293
+
294
+
295
+ # --------------------------------------------------------------------------- #
296
+ # DuckDB SQL path — set-based work over the Parquet store, out-of-core.
297
+ # --------------------------------------------------------------------------- #
298
+ def sql_query(
299
+ parquet_glob: str | Sequence[str],
300
+ sql: str,
301
+ *,
302
+ params: Optional[Sequence] = None,
303
+ view: str = "bars",
304
+ ) -> "pl.DataFrame":
305
+ """Run a DuckDB SQL query over Parquet, returned as a Polars frame (zero-copy Arrow).
306
+
307
+ Registers ``parquet_glob`` (a path/glob or a list of paths) as a view named
308
+ ``view`` (default ``bars``) so the query reads ``FROM bars``. DuckDB's reader does
309
+ its own projection/predicate pushdown and streams the scan, so an aggregation over
310
+ a multi-file store never materializes the whole thing. The result returns as
311
+ Polars (shared Arrow) — collapse it to pandas only at the edge via
312
+ :func:`quantred.data.edges.to_pandas`.
313
+
314
+ ``params`` binds DuckDB ``?`` placeholders (e.g. an ``as_of`` cutoff), keeping the
315
+ point-in-time filter inside the pushed-down scan rather than a post-filter.
316
+ """
317
+ import duckdb
318
+
319
+ paths: List[str] = [parquet_glob] if isinstance(parquet_glob, str) else list(parquet_glob)
320
+ con = duckdb.connect()
321
+ try:
322
+ # hive_partitioning=false: the store embeds `symbol` as a real column, so we
323
+ # must not also synthesize it from the `symbol=…` path (that would collide).
324
+ # Register the scan as a relation (CREATE VIEW can't take a prepared param).
325
+ con.register(view, con.read_parquet(paths, hive_partitioning=False))
326
+ arrow = con.execute(sql, list(params) if params is not None else None).to_arrow_table()
327
+ finally:
328
+ con.close()
329
+
330
+ import polars as pl
331
+
332
+ return pl.from_arrow(arrow)