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,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}