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,135 @@
|
|
|
1
|
+
"""Constraint-solver portfolio allocation - because "put it all on the one with the
|
|
2
|
+
highest score" is a strategy, just not a good one.
|
|
3
|
+
|
|
4
|
+
Given a set of candidate symbols, each with a *score* (a higher score = more
|
|
5
|
+
attractive, however the caller chooses to define it - expected return, signal
|
|
6
|
+
strength, momentum, inverse volatility, ...), decide how to weight a portfolio
|
|
7
|
+
across them subject to hard constraints:
|
|
8
|
+
|
|
9
|
+
* invest at most 100% of capital,
|
|
10
|
+
* hold at most ``max_positions`` names (cardinality),
|
|
11
|
+
* cap any single name at ``max_weight`` and floor a *held* name at ``min_weight``.
|
|
12
|
+
|
|
13
|
+
This is a small mixed-integer program solved with Google OR-Tools. Binary
|
|
14
|
+
"is this name held?" variables enforce the cardinality and min-weight-if-held
|
|
15
|
+
constraints; continuous weight variables carry the allocation.
|
|
16
|
+
|
|
17
|
+
OR-Tools is an optional dependency (``portfolio`` extra) and is imported lazily,
|
|
18
|
+
so the base install stays lean.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import List
|
|
24
|
+
|
|
25
|
+
from quantred.utils.numeric import round_quantity
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Candidate:
|
|
32
|
+
"""A symbol eligible for allocation."""
|
|
33
|
+
|
|
34
|
+
symbol: str
|
|
35
|
+
score: float # higher = more attractive (caller-defined factor)
|
|
36
|
+
price: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Allocation:
|
|
41
|
+
"""A solved allocation for one symbol."""
|
|
42
|
+
|
|
43
|
+
symbol: str
|
|
44
|
+
weight: float # fraction of capital [0, 1]
|
|
45
|
+
dollars: float
|
|
46
|
+
shares: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PortfolioAllocator:
|
|
50
|
+
"""Weights positions across candidates via a MIP constraint solver."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
max_positions: int = 5,
|
|
55
|
+
max_weight: float = 0.25,
|
|
56
|
+
min_weight: float = 0.0,
|
|
57
|
+
allow_fractional_shares: bool = False,
|
|
58
|
+
):
|
|
59
|
+
if not 0 < max_weight <= 1:
|
|
60
|
+
raise ValueError("max_weight must be in (0, 1]")
|
|
61
|
+
if not 0 <= min_weight <= max_weight:
|
|
62
|
+
raise ValueError("min_weight must be in [0, max_weight]")
|
|
63
|
+
self.max_positions = max_positions
|
|
64
|
+
self.max_weight = max_weight
|
|
65
|
+
self.min_weight = min_weight
|
|
66
|
+
self.allow_fractional_shares = allow_fractional_shares
|
|
67
|
+
|
|
68
|
+
def allocate(self, candidates: List[Candidate], capital: float) -> List[Allocation]:
|
|
69
|
+
"""Return the optimal allocation across ``candidates`` for ``capital``.
|
|
70
|
+
|
|
71
|
+
Only candidates with a positive score are considered (a non-positive
|
|
72
|
+
score never improves the objective and would only consume a slot).
|
|
73
|
+
"""
|
|
74
|
+
eligible = [c for c in candidates if c.score > 0 and c.price > 0]
|
|
75
|
+
if not eligible or capital <= 0:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
solver = self._make_solver()
|
|
79
|
+
weights, selected = self._build_model(solver, eligible)
|
|
80
|
+
|
|
81
|
+
status = solver.Solve()
|
|
82
|
+
if status not in (solver.OPTIMAL, solver.FEASIBLE):
|
|
83
|
+
logger.warning("Portfolio solver returned no usable solution (status=%s)", status)
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
allocations = []
|
|
87
|
+
for candidate, weight_var, select_var in zip(eligible, weights, selected):
|
|
88
|
+
weight = weight_var.solution_value()
|
|
89
|
+
if select_var.solution_value() < 0.5 or weight <= 1e-9:
|
|
90
|
+
continue
|
|
91
|
+
dollars = weight * capital
|
|
92
|
+
allocations.append(
|
|
93
|
+
Allocation(
|
|
94
|
+
symbol=candidate.symbol,
|
|
95
|
+
weight=weight,
|
|
96
|
+
dollars=dollars,
|
|
97
|
+
shares=round_quantity(dollars / candidate.price, self.allow_fractional_shares),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return sorted(allocations, key=lambda a: a.weight, reverse=True)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ #
|
|
103
|
+
# Model construction
|
|
104
|
+
# ------------------------------------------------------------------ #
|
|
105
|
+
def _build_model(self, solver, candidates: List[Candidate]):
|
|
106
|
+
weights, selected = [], []
|
|
107
|
+
for candidate in candidates:
|
|
108
|
+
weight = solver.NumVar(0.0, self.max_weight, f"w_{candidate.symbol}")
|
|
109
|
+
select = solver.BoolVar(f"x_{candidate.symbol}")
|
|
110
|
+
# A name's weight is bounded by its selection flag (0 weight if unheld),
|
|
111
|
+
# and floored at min_weight when held.
|
|
112
|
+
solver.Add(weight <= self.max_weight * select)
|
|
113
|
+
solver.Add(weight >= self.min_weight * select)
|
|
114
|
+
weights.append(weight)
|
|
115
|
+
selected.append(select)
|
|
116
|
+
|
|
117
|
+
solver.Add(solver.Sum(weights) <= 1.0) # invest <= 100%
|
|
118
|
+
solver.Add(solver.Sum(selected) <= self.max_positions) # cardinality cap
|
|
119
|
+
|
|
120
|
+
solver.Maximize(solver.Sum(c.score * w for c, w in zip(candidates, weights)))
|
|
121
|
+
return weights, selected
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _make_solver():
|
|
125
|
+
try:
|
|
126
|
+
from ortools.linear_solver import pywraplp
|
|
127
|
+
except ImportError as exc: # pragma: no cover - depends on optional extra
|
|
128
|
+
from quantred.utils.extras import missing_extra_message
|
|
129
|
+
|
|
130
|
+
raise RuntimeError(missing_extra_message("The portfolio allocator", "portfolio")) from exc
|
|
131
|
+
|
|
132
|
+
solver = pywraplp.Solver.CreateSolver("CBC")
|
|
133
|
+
if solver is None: # pragma: no cover
|
|
134
|
+
raise RuntimeError("Could not create an OR-Tools CBC solver")
|
|
135
|
+
return solver
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The benchmark as a portfolio: loading ``w_B``, and reverse optimization.
|
|
2
|
+
|
|
3
|
+
Everywhere else in the stack a benchmark is a *return series* (e.g. SPY closes) -
|
|
4
|
+
betas and residual vols regress against it. This module is the other half: a
|
|
5
|
+
benchmark as a *holdings vector*, which is what makes "tracking error" the real
|
|
6
|
+
thing (``ψ = √(w_aᵀΣw_a)``, active weights ``w_a = w − w_B``) instead of the total
|
|
7
|
+
volatility of a cash-relative book. Pure, dependency-light functions - no data
|
|
8
|
+
fetching here (that's the caller's job); this module only turns a raw weights
|
|
9
|
+
mapping into something the optimizer and reverse-optimization report can use.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Iterable, Mapping, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from quantred.risk.base import RiskMatrix
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_benchmark_weights(
|
|
21
|
+
source: Union[str, Mapping[str, float]], symbols: Iterable[str]
|
|
22
|
+
) -> Dict[str, float]:
|
|
23
|
+
"""Load ``w_B`` from ``"equal"``, a holdings file, or a mapping given directly.
|
|
24
|
+
|
|
25
|
+
The mapping form exists because not every caller has a filesystem to point at. The
|
|
26
|
+
agent surface passes weights as data and is deliberately never given a *path*: a
|
|
27
|
+
parameter that names a file is a file-read primitive, and this surface's whole
|
|
28
|
+
premise is that it holds no capability it was not handed. Same normalization either
|
|
29
|
+
way, so a book loaded from disk and the same book passed inline cannot disagree.
|
|
30
|
+
|
|
31
|
+
A file is CSV (``symbol,weight`` header + rows) or JSON (``{"symbol": weight}``).
|
|
32
|
+
Cap-proxy weighting (shares outstanding) is deliberately not supported in v1 -
|
|
33
|
+
``equal`` + a user-supplied file covers it for now.
|
|
34
|
+
|
|
35
|
+
Always renormalized to sum to 1: a benchmark that isn't fully invested (holds
|
|
36
|
+
cash) is folded pro-rata into its named holdings rather than modeled as a
|
|
37
|
+
genuine zero-variance asset - a documented v1 simplification, not a hidden
|
|
38
|
+
one - callers that care can compare against ``raw_weight_sum``
|
|
39
|
+
before renormalization.
|
|
40
|
+
"""
|
|
41
|
+
symbols = list(symbols)
|
|
42
|
+
if not isinstance(source, str):
|
|
43
|
+
weights = {str(k): float(v) for k, v in dict(source).items()}
|
|
44
|
+
total = sum(weights.values())
|
|
45
|
+
if total <= 0:
|
|
46
|
+
raise ValueError("benchmark holdings mapping has no positive weight")
|
|
47
|
+
return {s: w / total for s, w in weights.items()}
|
|
48
|
+
|
|
49
|
+
if source == "equal":
|
|
50
|
+
if not symbols:
|
|
51
|
+
return {}
|
|
52
|
+
w = 1.0 / len(symbols)
|
|
53
|
+
return {s: w for s in symbols}
|
|
54
|
+
|
|
55
|
+
path = Path(source)
|
|
56
|
+
if not path.exists():
|
|
57
|
+
raise ValueError(f"benchmark holdings source {source!r} is neither 'equal' nor an existing file")
|
|
58
|
+
|
|
59
|
+
if path.suffix.lower() == ".json":
|
|
60
|
+
raw = json.loads(path.read_text())
|
|
61
|
+
weights = {str(k): float(v) for k, v in raw.items()}
|
|
62
|
+
elif path.suffix.lower() == ".csv":
|
|
63
|
+
weights = {}
|
|
64
|
+
with path.open(newline="") as fh:
|
|
65
|
+
for row in csv.DictReader(fh):
|
|
66
|
+
weights[str(row["symbol"]).strip()] = float(row["weight"])
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError(f"benchmark holdings file must be .csv or .json, got {path.suffix!r}")
|
|
69
|
+
|
|
70
|
+
total = sum(weights.values())
|
|
71
|
+
if total <= 0:
|
|
72
|
+
raise ValueError(f"benchmark holdings file {source!r} has no positive weight")
|
|
73
|
+
return {s: w / total for s, w in weights.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def restrict_and_renormalize(
|
|
77
|
+
weights: Dict[str, float], covered_symbols: Iterable[str]
|
|
78
|
+
) -> Tuple[Dict[str, float], float]:
|
|
79
|
+
"""Restrict ``w_B`` to names Σ actually covers, and renormalize to sum to 1.
|
|
80
|
+
|
|
81
|
+
Reverse optimization and tracking error need Σ to span the benchmark; when it
|
|
82
|
+
only spans the trading universe, the sanctioned v1 alternative to extending
|
|
83
|
+
the panel is this - restrict and renormalize, *loudly*: returns the coverage
|
|
84
|
+
fraction (of raw weight mass) alongside
|
|
85
|
+
the restricted weights, so a caller can warn when it's materially less than 1.
|
|
86
|
+
"""
|
|
87
|
+
covered = set(covered_symbols)
|
|
88
|
+
raw_total = sum(weights.values())
|
|
89
|
+
kept = {s: w for s, w in weights.items() if s in covered}
|
|
90
|
+
kept_total = sum(kept.values())
|
|
91
|
+
coverage = (kept_total / raw_total) if raw_total > 0 else 0.0
|
|
92
|
+
if kept_total <= 0:
|
|
93
|
+
return {}, coverage
|
|
94
|
+
return {s: w / kept_total for s, w in kept.items()}, coverage
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def implied_returns(benchmark_weights: Dict[str, float], risk: RiskMatrix, mu_b: float) -> Dict[str, float]:
|
|
98
|
+
"""Reverse optimization: the consensus returns for which
|
|
99
|
+
the benchmark ``w_B`` is itself mean-variance optimal.
|
|
100
|
+
|
|
101
|
+
``β = Σw_B/(w_Bᵀ Σ w_B)`` (the one canonical benchmark beta -
|
|
102
|
+
:meth:`RiskMatrix.implied_beta`), ``μ = β·μ_B`` for a stated benchmark premium
|
|
103
|
+
``μ_B``. Feeding ``μ`` back into :meth:`~quantred.portfolio.optimizer.MeanVarianceOptimizer.optimize`
|
|
104
|
+
with this same ``w_B`` and zero cost returns ``w = w_B`` exactly - the sharpest
|
|
105
|
+
integration test available, and the corollary this function exists to enable:
|
|
106
|
+
our alphas are *deviations from* this consensus.
|
|
107
|
+
"""
|
|
108
|
+
beta = risk.implied_beta(benchmark_weights)
|
|
109
|
+
return {sym: float(beta[sym] * mu_b) for sym in risk.symbols}
|