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,150 @@
1
+ """Position-sizing & portfolio-allocation services.
2
+
3
+ The shared business logic for turning a universe + market data into position
4
+ sizes: trailing-return scoring, OR-Tools portfolio weights, and beta-scaled
5
+ sizing. It lives here - not in the CLI - so the CLI, the MCP server, and the
6
+ research agent all reach it through one path, the same "reuse the core" rule the
7
+ rest of ``services/`` follows. The CLI is just another adapter; it shouldn't own
8
+ domain logic any more than the MCP server does.
9
+ """
10
+
11
+ import logging
12
+ from datetime import datetime
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ from quantred.execution.sizing import BetaSizer, PortfolioWeightSizer
16
+ from quantred.indicators.indicators import calculate_beta
17
+ from quantred.marketdata.client import MarketDataClient
18
+ from quantred.portfolio.allocator import Candidate
19
+ from quantred.strategies.base import Strategy
20
+ from quantred.utils.timeutils import shift_days
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def score_candidates(
26
+ data_client: MarketDataClient,
27
+ symbols: List[str],
28
+ timeframe: str,
29
+ lookback_days: int = 90,
30
+ ) -> List[Candidate]:
31
+ """Score symbols by trailing return over a recent window -> ``[Candidate]``.
32
+
33
+ Trailing return is a simple, transparent factor; swap in momentum /
34
+ inverse-vol / signal strength here. Shared by ``allocate`` and portfolio-sized
35
+ ``live``.
36
+ """
37
+ end = datetime.now()
38
+ bars = data_client.get_bars(symbols, timeframe, shift_days(end, -lookback_days), end)
39
+ candidates: List[Candidate] = []
40
+ for symbol in symbols:
41
+ frame = bars.get(symbol)
42
+ if frame is None or len(frame) < 2:
43
+ continue
44
+ trailing_return = frame["close"].iloc[-1] / frame["close"].iloc[0] - 1
45
+ candidates.append(Candidate(symbol, score=max(trailing_return, 0.0), price=frame["close"].iloc[-1]))
46
+ return candidates
47
+
48
+
49
+ def allocate_portfolio(
50
+ data_client: MarketDataClient,
51
+ symbols: List[str],
52
+ timeframe: str,
53
+ capital: float,
54
+ max_positions: int,
55
+ max_weight: float,
56
+ ):
57
+ """Scan-agnostic allocation: score ``symbols``, solve weights via OR-Tools."""
58
+ from quantred.portfolio.allocator import PortfolioAllocator
59
+
60
+ candidates = score_candidates(data_client, symbols, timeframe)
61
+ allocator = PortfolioAllocator(max_positions=max_positions, max_weight=max_weight)
62
+ return allocator.allocate(candidates, capital)
63
+
64
+
65
+ def build_portfolio_weight_sizer(
66
+ data_client: MarketDataClient,
67
+ equity: float,
68
+ symbols: List[str],
69
+ timeframe: str,
70
+ max_positions: int,
71
+ max_weight: float,
72
+ ) -> Optional[PortfolioWeightSizer]:
73
+ """Allocate capital across ``symbols`` and wrap the weights in a sizer.
74
+
75
+ Returns ``None`` (so the caller falls back to risk-based sizing) if OR-Tools
76
+ is missing or the allocator funds nothing. The returned sizer exposes its
77
+ funded ``symbols`` for convenience.
78
+ """
79
+ try:
80
+ allocations = allocate_portfolio(data_client, symbols, timeframe, equity, max_positions, max_weight)
81
+ except RuntimeError as exc: # OR-Tools not installed
82
+ logger.warning("%s\nFalling back to risk-based sizing.", exc)
83
+ return None
84
+
85
+ weights = {a.symbol: a.weight for a in allocations}
86
+ if not weights:
87
+ logger.warning("Portfolio allocator funded nothing; using risk-based sizing.")
88
+ return None
89
+
90
+ logger.info("Portfolio-weighted sizing: %s", {s: round(w, 3) for s, w in weights.items()})
91
+ sizer = PortfolioWeightSizer(weights)
92
+ sizer.symbols = list(weights) # convenience for the caller
93
+ return sizer
94
+
95
+
96
+ BETA_LOOKBACK_DAYS = 90
97
+
98
+
99
+ def beta_read(
100
+ symbols: List[str],
101
+ benchmark: str = "SPY",
102
+ lookback_days: int = BETA_LOOKBACK_DAYS,
103
+ as_of: Optional[datetime] = None,
104
+ ) -> Tuple[List[str], str, datetime, datetime]:
105
+ """The one fetch beta sizing makes, as ``get_bars`` arguments.
106
+
107
+ Public so an offline preflight asks the cache about exactly the read
108
+ :func:`compute_betas` will make, rather than restating its window.
109
+ """
110
+ end = as_of or datetime.now()
111
+ return [benchmark, *symbols], "1Day", shift_days(end, -lookback_days), end
112
+
113
+
114
+ def compute_betas(
115
+ data_client: MarketDataClient,
116
+ symbols: List[str],
117
+ benchmark: str = "SPY",
118
+ lookback_days: int = BETA_LOOKBACK_DAYS,
119
+ as_of: Optional[datetime] = None,
120
+ ) -> Dict[str, float]:
121
+ """Beta of each symbol vs a benchmark over a trailing daily window.
122
+
123
+ For backtests, pass ``as_of=start`` so betas use only data *before* the
124
+ backtest window (no look-ahead). Returns ``{}`` if the benchmark is missing,
125
+ which leaves :class:`BetaSizer` to fall back to neutral betas.
126
+ """
127
+ bars = data_client.get_bars(*beta_read(symbols, benchmark, lookback_days, as_of))
128
+ benchmark_bars = bars.get(benchmark)
129
+ if benchmark_bars is None or benchmark_bars.empty:
130
+ logger.warning("No %s data; beta sizing will fall back to neutral beta", benchmark)
131
+ return {}
132
+ return {
133
+ symbol: calculate_beta(bars[symbol]["close"], benchmark_bars["close"])
134
+ for symbol in symbols
135
+ if symbol in bars and not bars[symbol].empty
136
+ }
137
+
138
+
139
+ def build_beta_sizer(
140
+ data_client: MarketDataClient,
141
+ strategy: Strategy,
142
+ symbols: List[str],
143
+ benchmark: str = "SPY",
144
+ as_of: Optional[datetime] = None,
145
+ ) -> BetaSizer:
146
+ """Construct a :class:`BetaSizer` (neutral for any symbol with no computable beta)."""
147
+ betas = compute_betas(data_client, symbols, benchmark, as_of=as_of)
148
+ if betas:
149
+ logger.info("Beta sizing: %s", {s: round(b, 2) for s, b in betas.items()})
150
+ return BetaSizer(strategy, betas)
@@ -0,0 +1,450 @@
1
+ """The validated contract at reduced capital, with its proportions intact.
2
+
3
+ Observing execution — real fills, real slippage, real fees — means placing real orders,
4
+ and placing them at full size is not something anybody wants to do to find out whether
5
+ the plumbing works. So the book gets smaller. The question this module exists to answer
6
+ is *which numbers move when it does*, because the obvious answer is wrong and the wrong
7
+ answer is what caused the problem this mode was built to remove: a position ceiling
8
+ shrunk by hand until fills happened, which biases the sample toward low-priced names and
9
+ turns every high-price signal into an invisible non-trade. The strategy under observation
10
+ stops being the strategy that was validated, and the telemetry describes a book nobody
11
+ would trade.
12
+
13
+ **The rule, in one line:**
14
+
15
+ Fractions scale. Counts stay counts. Dollar strategy limits scale.
16
+ Venue floors stay absolute.
17
+
18
+ Each clause is a different unit, and applying one clause to another clause's limit is a
19
+ distortion in one direction or the other:
20
+
21
+ * ``max_gross_exposure``, ``max_net_exposure`` and ``max_total_risk`` are fractions *of
22
+ capital*. They already scale, because capital did. Multiplying them by the shrink
23
+ factor as well would shrink them twice — a 0.80 gross cap becoming 0.04 — and bind far
24
+ tighter than anything that was validated.
25
+ * ``max_positions`` is the book's *shape*: how many names compete for the same budget,
26
+ and therefore how diversified the thing being measured is. Shrinking it is precisely
27
+ the distortion, arriving through the front door.
28
+ * ``max_position_size`` is in dollars. A $12,500 ceiling on a $250,000 book is a 5%
29
+ ceiling; left at $12,500 over $12,500 of capital it is above the whole book and binds
30
+ nothing, so the validated contract had a ceiling and the run would have none.
31
+ * ``min_notional`` is a venue fact, not a share of the book. Scaling it down would claim
32
+ the broker accepts smaller orders than it does, which makes the run easier to fill and
33
+ less real.
34
+
35
+ **The floor not scaling is a cost, taken deliberately and then measured.** It will refuse
36
+ more orders at small capital, and refuse them unevenly — expensive names first. So will
37
+ share granularity, which no amount of care avoids: a book with a few hundred dollars per
38
+ position cannot buy one share of a four-figure stock. Rather than pretend otherwise, the
39
+ preflight reports the price above which a name cannot be traded at all at this scale, and
40
+ the refusals are coded so they can be counted. The bias becomes a number in the report
41
+ instead of a silence in the sample.
42
+ """
43
+
44
+ from typing import Any, Dict, Optional, Tuple
45
+
46
+ #: The rule, kept as a string because it is quoted in the preflight and in the docs and
47
+ #: must not be paraphrased differently in three places.
48
+ SCALING_RULE = (
49
+ "Fractions scale. Counts stay counts. Dollar strategy limits scale. Venue floors stay absolute."
50
+ )
51
+
52
+ #: Fractions *of capital*. Already proportional, so the declared value is carried across
53
+ #: untouched — scaling it would apply the shrink a second time.
54
+ FRACTIONS = ("max_gross_exposure", "max_net_exposure", "max_total_risk")
55
+
56
+ #: The book's shape. How many names share the budget is what is being measured, not how
57
+ #: much money is in it.
58
+ COUNTS = ("max_positions",)
59
+
60
+ #: Dollar ceilings that express a share of the book, and therefore have to be restated
61
+ #: when the book changes size.
62
+ DOLLAR_CEILINGS = ("max_position_size",)
63
+
64
+ #: Dollar floors that express a fact about the venue. The account's minimum does not get
65
+ #: smaller because this run chose to.
66
+ VENUE_FLOORS = ("min_notional",)
67
+
68
+
69
+ class ContractError(ValueError):
70
+ """A contract that cannot be scaled, with the reason and the way out.
71
+
72
+ Every one of these is a refusal to start rather than a warning to read, because the
73
+ thing on the other side places real orders. The message says what was missing and
74
+ what to do instead: a refusal an operator cannot act on gets worked around.
75
+ """
76
+
77
+
78
+ def validated_contract(
79
+ *,
80
+ config_capital: Optional[float],
81
+ config_limits: Optional[Dict[str, Any]],
82
+ strategy_class,
83
+ ) -> Tuple[Optional[float], Dict[str, Any]]:
84
+ """What the config says was validated: ``(capital, book)``.
85
+
86
+ The book is resolved through the one resolver that also writes saved configs, so a
87
+ contract read here and a config written elsewhere cannot disagree about it. A
88
+ partial ``position_limits`` is merged over the class defaults, because that is what
89
+ the run would actually trade.
90
+
91
+ A config that recorded no book at all is refused. Falling back to the class default
92
+ would scale proportions nobody validated — which is the defect that made a config
93
+ recording an eight-position validation trade one position, one step earlier in the
94
+ chain. The capital may legitimately be absent, and the caller decides whether the
95
+ scale it was given can survive that.
96
+ """
97
+ from quantred.strategies.base import resolve_book
98
+
99
+ if config_capital is not None and float(config_capital) <= 0:
100
+ raise ContractError(
101
+ f"this config records a validated capital of {config_capital}, which is not "
102
+ "an amount anything can have been validated at. A fraction of it is not a "
103
+ "smaller book, it is a meaningless one — and the run would size every "
104
+ "position to nothing while looking like it was trading.\n"
105
+ " Fix the capital in the config, or pass --capital to state this run's own."
106
+ )
107
+ if not config_limits:
108
+ raise ContractError(
109
+ "this config records no position_limits, so there is no validated book to "
110
+ "take proportions from. Small-real trades the validated contract at reduced "
111
+ "capital; without the contract it would be scaling the strategy class's "
112
+ "defaults, which nobody validated.\n"
113
+ " Promote a config from a recorded trial (`trials promote <id> "
114
+ "--save-config PATH`), which writes the book it was validated at."
115
+ )
116
+ return config_capital, resolve_book(strategy_class, config_limits)
117
+
118
+
119
+ def resolve_scale(
120
+ *,
121
+ validated_capital: Optional[float],
122
+ scale: Optional[float] = None,
123
+ capital: Optional[float] = None,
124
+ ) -> Tuple[float, Optional[float], str]:
125
+ """``(capital to deploy, shrink factor, how it was stated)``.
126
+
127
+ Exactly one of ``scale`` and ``capital``. There is deliberately no default: a run
128
+ that can place real orders must not deploy an amount nobody chose, which is the same
129
+ rule the dry run applies to its stated capital and the same class of defect as a
130
+ position book that quietly resolved to one.
131
+
132
+ Both together are refused rather than reconciled even when they agree. Two sources
133
+ for one number is a thing to keep in step, and the arithmetic that checks them
134
+ agree is the arithmetic that would be wrong.
135
+
136
+ ``scale`` needs a recorded validated capital to be a fraction *of* something, and so
137
+ does a ``capital`` whenever the book carries a dollar ceiling (see
138
+ :func:`unscalable_ceilings`) - which every strategy's book does. Where the config has
139
+ none, both are refused with the same way forward: a config that records the capital
140
+ it was validated at.
141
+ """
142
+ if scale is None and capital is None:
143
+ raise ContractError(
144
+ "small-real needs the size of the run stated: pass --scale (a fraction of "
145
+ "the validated capital) or --capital (an amount). There is no default, "
146
+ "because this mode places real orders and the amount it deploys must be one "
147
+ "somebody chose."
148
+ )
149
+ if scale is not None and capital is not None:
150
+ raise ContractError(
151
+ "--scale and --capital both state the size of this run, and two sources for "
152
+ "one number is one too many. Pass whichever you meant."
153
+ )
154
+
155
+ if scale is not None:
156
+ if validated_capital is None:
157
+ raise ContractError(
158
+ "--scale is a fraction of the capital this config was validated at, and "
159
+ "this config records none, so there is nothing to take a fraction of.\n"
160
+ " --capital cannot stand in for it: a dollar ceiling such as "
161
+ "max_position_size is restated by the ratio to the validated capital.\n"
162
+ " Use a config that records the capital it was validated at."
163
+ )
164
+ if scale <= 0:
165
+ raise ContractError(f"--scale must be positive, not {scale}")
166
+ if scale > 1:
167
+ raise ContractError(
168
+ f"--scale {scale} would deploy more than the capital this config was "
169
+ f"validated at (${validated_capital:,.2f}). Small-real runs the validated "
170
+ "contract smaller, never larger; there is no evidence for the book above."
171
+ )
172
+ return float(validated_capital) * float(scale), float(scale), f"--scale {scale:g}"
173
+
174
+ if capital <= 0:
175
+ raise ContractError(f"--capital must be positive, not {capital}")
176
+ if validated_capital is not None and capital > validated_capital:
177
+ raise ContractError(
178
+ f"--capital ${capital:,.2f} is more than the ${validated_capital:,.2f} this "
179
+ "config was validated at. Small-real runs the validated contract smaller, "
180
+ "never larger; there is no evidence for the book above."
181
+ )
182
+ # Absent stays absent. Without a validated capital there is no ratio, and reporting
183
+ # one derived from the deployed amount alone would be inventing the denominator.
184
+ derived = float(capital) / float(validated_capital) if validated_capital else None
185
+ return float(capital), derived, "--capital"
186
+
187
+
188
+ def scale_book(validated_book: Dict[str, Any], scale: Optional[float]) -> Dict[str, Any]:
189
+ """The validated book restated for a smaller amount of capital.
190
+
191
+ Every key of the input appears in the output, so a limit this rule has no opinion
192
+ about is carried rather than dropped — an omitted limit reads as unbounded, which is
193
+ the one direction a book must never move by accident.
194
+
195
+ ``scale`` of ``None`` means no ratio is known. The fractions and counts are still
196
+ correct (they do not depend on one), so the book is returned with its dollar
197
+ ceilings untouched; :func:`unscalable_ceilings` is what refuses that case before it
198
+ can be run.
199
+ """
200
+ scaled = dict(validated_book)
201
+ if scale is None:
202
+ return scaled
203
+ for key in DOLLAR_CEILINGS:
204
+ value = scaled.get(key)
205
+ if value is not None:
206
+ scaled[key] = float(value) * scale
207
+ return scaled
208
+
209
+
210
+ def unscalable_ceilings(validated_book: Dict[str, Any], scale: Optional[float]) -> Tuple[str, ...]:
211
+ """Dollar ceilings that cannot be restated because no ratio is known.
212
+
213
+ Reached only when a capital was named against a config that recorded none of its
214
+ own. A ceiling left at its validated value over a fraction of the capital is above
215
+ the whole book and binds nothing, so the run would be missing a limit the validated
216
+ contract had — silently, and in the direction that lets positions get too big.
217
+ """
218
+ if scale is not None:
219
+ return ()
220
+ return tuple(key for key in DOLLAR_CEILINGS if validated_book.get(key) is not None)
221
+
222
+
223
+ def per_position_budget(capital: float, book: Dict[str, Any]) -> Optional[float]:
224
+ """Roughly what one position gets at this size, or ``None`` if nothing bounds it.
225
+
226
+ The smaller of the explicit dollar ceiling and an even split of the deployable book
227
+ across the names it may hold. Deliberately approximate and labelled as such wherever
228
+ it is shown: the sizer decides the real number from the strategy's risk budget and
229
+ the symbol's stop distance, and it varies per name. What this is for is the order of
230
+ magnitude — whether a position at this scale is worth hundreds or thousands — which
231
+ is what decides whether the run can trade its universe at all.
232
+ """
233
+ candidates = []
234
+ ceiling = book.get("max_position_size")
235
+ if ceiling is not None:
236
+ candidates.append(float(ceiling))
237
+ positions = book.get("max_positions")
238
+ if positions:
239
+ # `is not None` above and truthiness here, deliberately: a ceiling of zero is a
240
+ # real ceiling (nothing may be deployed), while a *count* of zero cannot be
241
+ # divided by. A book that may hold no positions has no per-position budget in
242
+ # the arithmetic sense, and the zero ceiling above already says so.
243
+ gross = book.get("max_gross_exposure")
244
+ # An undeclared gross cap leaves the whole capital deployable; a declared cap of
245
+ # zero leaves none of it. `or 1.0` read the second as the first, turning the most
246
+ # restrictive book into the most permissive number.
247
+ deployable = capital * (1.0 if gross is None else float(gross))
248
+ candidates.append(deployable / float(positions))
249
+ return min(candidates) if candidates else None
250
+
251
+
252
+ def floor_blocks_every_entry(capital: float, book: Dict[str, Any]) -> Optional[Dict[str, float]]:
253
+ """The venue floor standing above the largest order this scale can size, or ``None``.
254
+
255
+ The floor is absolute and the dollar ceilings are not, so shrinking capital far
256
+ enough eventually puts *every* order the book can size underneath it. That run is
257
+ not a smaller version of the validated contract - it is a contract that cannot enter
258
+ at all, and the difference is invisible in a column of scaled numbers, because every
259
+ line in it is individually correct.
260
+
261
+ Distinct from the bias this mode reports and cannot remove, where the floor refuses
262
+ the *expensive* names and the cheap ones still trade. That one is measured. This one
263
+ leaves nothing to measure.
264
+ """
265
+ budget = per_position_budget(capital, book)
266
+ floor = book.get("min_notional")
267
+ # `is None` rather than truthiness on both: a declared floor of zero is a real
268
+ # statement that the venue has none, and a budget of zero is a book that may deploy
269
+ # nothing - neither is an absence, and neither can be read as the other.
270
+ if budget is None or floor is None:
271
+ return None
272
+ if budget >= float(floor):
273
+ return None
274
+ return {"budget": float(budget), "floor": float(floor)}
275
+
276
+
277
+ #: Why the headline matters, kept apart so the headline stays one line. Both the
278
+ #: preflight and the refusal print the pair, so a reader who sees the finding in a
279
+ #: rehearsal and a reader who runs into it at the gate read the same words.
280
+ EVERY_ENTRY_BLOCKED_CONSEQUENCE = (
281
+ "No entry this run can size will reach the venue, so it observes no fills, no "
282
+ "slippage and no fees — there is nothing here for it to be evidence of."
283
+ )
284
+
285
+
286
+ def every_entry_blocked_note(contract_: Dict[str, Any]) -> Optional[str]:
287
+ """The headline, or ``None`` when the floor still leaves something tradable.
288
+
289
+ States the comparison rather than restating the two numbers and leaving it to the
290
+ reader - the preflight has already printed the budget and the floor on their own
291
+ lines, and a third printing would still be two numbers and no conclusion.
292
+ """
293
+ finding = contract_.get("floor_blocks_every_entry")
294
+ if not finding:
295
+ return None
296
+ return (
297
+ f"min_notional will bind every entry at this scale: max intended order "
298
+ f"~${finding['budget']:,.2f} < floor ${finding['floor']:,.2f}"
299
+ )
300
+
301
+
302
+ def max_loss_envelope(capital: float, book: Dict[str, Any]) -> Optional[float]:
303
+ """What the book gives up if every open position stops out, or ``None``.
304
+
305
+ ``max_total_risk`` is a stop-weighted budget, so this is exactly what it means:
306
+ the fraction of deployable capital lost when every stop fills at its price. That
307
+ condition is the whole caveat and it is stated wherever this is printed — a gap
308
+ through a stop fills below it, so the envelope is a floor on the loss and not a
309
+ ceiling on it.
310
+
311
+ ``None`` where no risk budget is declared, which is the honest answer. An undeclared
312
+ budget is unbounded, and rendering that as zero would print the most reassuring
313
+ possible number for the least bounded possible book.
314
+ """
315
+ risk = book.get("max_total_risk")
316
+ # `is not None`, not truthiness. A declared budget of zero permits no loss at all,
317
+ # and reading it as "undeclared" rendered the most restrictive possible book as the
318
+ # unbounded one — the project's own absent-is-not-zero rule, inverted: a recorded
319
+ # zero is not an absence.
320
+ return capital * float(risk) if risk is not None else None
321
+
322
+
323
+ def contract(
324
+ *,
325
+ validated_capital: Optional[float],
326
+ validated_book: Dict[str, Any],
327
+ scale: Optional[float],
328
+ capital: float,
329
+ capital_source: str,
330
+ ) -> Dict[str, Any]:
331
+ """The scaled contract as data, for a renderer to print and a ledger to record.
332
+
333
+ One object, so the preflight a human reads and the session header the telemetry
334
+ keeps cannot describe the run differently. Every limit is reported beside the
335
+ validated value it came from and the reason it did or did not move, because "this
336
+ number is unchanged" and "this number was overlooked" are indistinguishable in a
337
+ list of numbers.
338
+ """
339
+ scaled_book = scale_book(validated_book, scale)
340
+ treatments = {
341
+ **{key: "fraction of capital — unchanged" for key in FRACTIONS},
342
+ **{key: "count — unchanged" for key in COUNTS},
343
+ **{key: "dollar ceiling — scaled" for key in DOLLAR_CEILINGS},
344
+ **{key: "venue floor — not scaled" for key in VENUE_FLOORS},
345
+ }
346
+ return {
347
+ "mode": "small_real",
348
+ "rule": SCALING_RULE,
349
+ "validated_capital": validated_capital,
350
+ "capital": capital,
351
+ "capital_source": capital_source,
352
+ "scale": scale,
353
+ "validated_book": dict(validated_book),
354
+ "book": scaled_book,
355
+ "limit_treatment": {
356
+ key: treatments.get(key, "carried unchanged — no rule for this limit") for key in scaled_book
357
+ },
358
+ "per_position_budget": per_position_budget(capital, scaled_book),
359
+ # Recorded, not only printed: a session header that says the run could not
360
+ # enter is the difference between "this book found nothing" and "this book
361
+ # was never able to ask".
362
+ "floor_blocks_every_entry": floor_blocks_every_entry(capital, scaled_book),
363
+ "max_loss_envelope": max_loss_envelope(capital, scaled_book),
364
+ # Said rather than left to be inferred from a clean-looking report. This is the
365
+ # bias the mode cannot design away, only measure.
366
+ "not_covered": (
367
+ "a name priced above roughly one position's budget cannot be traded at this "
368
+ "scale at all, and the venue floor refuses more orders here than it did at "
369
+ "full size. Both refusals are counted in the telemetry; neither is a "
370
+ "property of the strategy"
371
+ ),
372
+ "journaled": False,
373
+ }
374
+
375
+
376
+ def adopted_book_note(held: int, book: Dict[str, Any]) -> Optional[str]:
377
+ """What positions already on the account mean for this run, or ``None``.
378
+
379
+ The engine adopts whatever the broker holds at start-up, which is right — a process
380
+ that believes it is flat cannot exit a position it owns. But a book carried over
381
+ from a full-size session is not this contract's book, and it lands in this run's
382
+ telemetry: exits of those positions are fills at the *old* size, recorded in a
383
+ ledger that exists precisely so two sizes are never averaged together.
384
+
385
+ Reported rather than refused. On a restart the adopted positions *are* this run's
386
+ own, and nothing here can tell the two cases apart; refusing would block the
387
+ legitimate one. What must not happen is the operator not being told — most sharply
388
+ when the adopted count already fills the book, because then no entry can be admitted
389
+ and the session measures nothing while looking like it is running.
390
+ """
391
+ if not held:
392
+ return None
393
+ # `is not None`: a book that may hold no positions is *definitionally* full, and
394
+ # truthiness silenced the warning for exactly that case.
395
+ limit = book.get("max_positions")
396
+ note = (
397
+ f"this account already holds {held} position(s) that this run did not open. The "
398
+ "engine will adopt them, so their exits will be recorded in this session's "
399
+ "telemetry at whatever size they were opened at — not at this run's."
400
+ )
401
+ if limit is not None and held >= int(limit):
402
+ note += (
403
+ f"\n They already fill the scaled book ({held} of {limit}), so no new entry "
404
+ "can be admitted and this session would measure no entry execution at all."
405
+ )
406
+ return note
407
+
408
+
409
+ def account_shortfall(account, capital: float) -> Optional[str]:
410
+ """Why this account cannot fund the scaled contract, or ``None`` if it can.
411
+
412
+ Sizing caps the account at the configured capital rather than replacing it, so an
413
+ account holding less than the scaled contract asks for does not fail — it quietly
414
+ trades something smaller, with all the proportions this module exists to preserve
415
+ silently wrong. That is the one outcome worse than refusing to start, because the
416
+ telemetry it produces looks exactly like telemetry from the contract that was asked
417
+ for.
418
+
419
+ **Every field the cap applies to is checked, not only equity.** The first version
420
+ checked equity alone, and the sizer sizes off ``buying_power`` — so an account with
421
+ ample equity and restricted buying power passed the guard and then traded a book
422
+ smaller than the contract, which is the exact failure this exists to prevent
423
+ arriving one field over. Whichever field binds is the one that decides the book, so
424
+ the guard has to look at all of them.
425
+
426
+ An unreadable account returns ``None``: the preflight already reports that it could
427
+ not be read, and refusing on an absent number would turn a broker hiccup into a
428
+ claim about the balance.
429
+ """
430
+ if account is None:
431
+ return None
432
+ short = {
433
+ name: float(value)
434
+ for name, value in (
435
+ ("equity", getattr(account, "equity", None)),
436
+ ("cash", getattr(account, "cash", None)),
437
+ ("buying power", getattr(account, "buying_power", None)),
438
+ )
439
+ if value is not None and float(value) < capital
440
+ }
441
+ if not short:
442
+ return None
443
+ detail = ", ".join(f"{name} ${value:,.2f}" for name, value in short.items())
444
+ return (
445
+ f"this account cannot fund the ${capital:,.2f} this run is sized for: {detail}. "
446
+ "Sizing caps at whatever the account has — and the sizer sizes off buying power "
447
+ "— so the run would trade a smaller book than the one whose proportions it "
448
+ "claims to preserve, and the telemetry would not say so.\n"
449
+ " Fund the account, or lower the scale to one it can carry."
450
+ )