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
|
@@ -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
|
+
]
|
quantred/data/compute.py
ADDED
|
@@ -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)
|