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,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
|
+
)
|