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,180 @@
|
|
|
1
|
+
"""Roll up what the live path actually did, from the ledger.
|
|
2
|
+
|
|
3
|
+
Research-clock code: it reads the trade clock's append-only record and summarises it.
|
|
4
|
+
Nothing here is a gate, and nothing here changes a run. What "bad" slippage looks like
|
|
5
|
+
for a given strategy is not knowable from one session, so this reports the numbers and
|
|
6
|
+
declines to grade them.
|
|
7
|
+
|
|
8
|
+
The reporting rules that matter here:
|
|
9
|
+
|
|
10
|
+
* **Absent is not zero.** A fill with no recorded price contributes no slippage, and
|
|
11
|
+
is counted as unmeasured rather than averaged in as zero.
|
|
12
|
+
* **Say what was not measured.** Every summary carries how many rows it could not
|
|
13
|
+
evaluate, because a clean-looking average over two of twenty fills is worse than no
|
|
14
|
+
average at all.
|
|
15
|
+
* **A modelled cost is not an observed one.** The cost model's estimate and the
|
|
16
|
+
venue's fee are reported separately and never summed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from statistics import median
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _measured(rows: List[Dict[str, Any]], key: str) -> List[float]:
|
|
24
|
+
return [row[key] for row in rows if row.get(key) is not None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def slippage_summary(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
28
|
+
"""Slippage across filled orders, in basis points, positive meaning worse."""
|
|
29
|
+
values = _measured(rows, "slippage_bps")
|
|
30
|
+
filled = [row for row in rows if row.get("filled_qty")]
|
|
31
|
+
summary = {
|
|
32
|
+
"n_filled": len(filled),
|
|
33
|
+
"n_measured": len(values),
|
|
34
|
+
"n_unmeasured": len(filled) - len(values),
|
|
35
|
+
"mean_bps": None,
|
|
36
|
+
"median_bps": None,
|
|
37
|
+
"worst_bps": None,
|
|
38
|
+
"worst_symbol": None,
|
|
39
|
+
"best_bps": None,
|
|
40
|
+
}
|
|
41
|
+
if not values:
|
|
42
|
+
return summary
|
|
43
|
+
worst = max(rows, key=lambda r: r.get("slippage_bps") if r.get("slippage_bps") is not None else -1e9)
|
|
44
|
+
summary.update(
|
|
45
|
+
mean_bps=sum(values) / len(values),
|
|
46
|
+
median_bps=median(values),
|
|
47
|
+
worst_bps=max(values),
|
|
48
|
+
worst_symbol=worst.get("symbol"),
|
|
49
|
+
best_bps=min(values),
|
|
50
|
+
)
|
|
51
|
+
return summary
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def latency_summary(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
55
|
+
"""How long the venue took, from the decision to the fill it produced.
|
|
56
|
+
|
|
57
|
+
Negative elapsed times are counted apart and never averaged in. They mean our
|
|
58
|
+
clock and the venue's disagree, not that a fill arrived before it was requested —
|
|
59
|
+
and a mean quietly dragged negative by skew is worse than an admission that some
|
|
60
|
+
rows could not be timed.
|
|
61
|
+
"""
|
|
62
|
+
values = _measured(rows, "decision_to_fill_ms")
|
|
63
|
+
forward = [value for value in values if value >= 0]
|
|
64
|
+
summary = {
|
|
65
|
+
"n_measured": len(forward),
|
|
66
|
+
"n_clock_skew": len(values) - len(forward),
|
|
67
|
+
"median_ms": None,
|
|
68
|
+
"worst_ms": None,
|
|
69
|
+
}
|
|
70
|
+
if forward:
|
|
71
|
+
summary.update(median_ms=median(forward), worst_ms=max(forward))
|
|
72
|
+
return summary
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fill_summary(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
76
|
+
"""Submitted versus filled, in shares and in notional."""
|
|
77
|
+
submitted_notional = sum(
|
|
78
|
+
abs(row["submitted_qty"]) * row["reference_price"]
|
|
79
|
+
for row in rows
|
|
80
|
+
if row.get("submitted_qty") and row.get("reference_price")
|
|
81
|
+
)
|
|
82
|
+
filled_notional = sum(
|
|
83
|
+
abs(row["filled_qty"]) * row["fill_price"]
|
|
84
|
+
for row in rows
|
|
85
|
+
if row.get("filled_qty") and row.get("fill_price")
|
|
86
|
+
)
|
|
87
|
+
unfilled = [row for row in rows if not row.get("filled_qty")]
|
|
88
|
+
# Two different things, and conflating them reported "0 partial" about a session
|
|
89
|
+
# where both orders filled in several prints. One is an outcome (the order ended
|
|
90
|
+
# short of what was asked), the other is a route (it took more than one print to
|
|
91
|
+
# get there) - and only the first is a problem.
|
|
92
|
+
short = [
|
|
93
|
+
row
|
|
94
|
+
for row in rows
|
|
95
|
+
if row.get("filled_qty")
|
|
96
|
+
and row.get("submitted_qty")
|
|
97
|
+
and abs(row["filled_qty"]) < abs(row["submitted_qty"]) - 1e-9
|
|
98
|
+
]
|
|
99
|
+
multi_print = [row for row in rows if (row.get("n_fill_events") or 0) > 1]
|
|
100
|
+
return {
|
|
101
|
+
"n_orders": len(rows),
|
|
102
|
+
"n_unfilled": len(unfilled),
|
|
103
|
+
"n_short": len(short),
|
|
104
|
+
"n_multi_print": len(multi_print),
|
|
105
|
+
"submitted_notional": submitted_notional,
|
|
106
|
+
"filled_notional": filled_notional,
|
|
107
|
+
# None rather than 0.0: with nothing submitted there is no ratio to report,
|
|
108
|
+
# and 0.0 would read as "nothing filled".
|
|
109
|
+
"fill_ratio": (filled_notional / submitted_notional) if submitted_notional else None,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cost_summary(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
114
|
+
"""Modelled cost and observed fees, kept apart on purpose."""
|
|
115
|
+
estimates = [row["cost_estimate"] for row in rows if isinstance(row.get("cost_estimate"), dict)]
|
|
116
|
+
fees = _measured(rows, "broker_fee")
|
|
117
|
+
excluded = sorted({part for estimate in estimates for part in estimate.get("excludes", [])})
|
|
118
|
+
return {
|
|
119
|
+
"n_estimated": len(estimates),
|
|
120
|
+
"model_cost_estimate": sum(e.get("total") or 0.0 for e in estimates) if estimates else None,
|
|
121
|
+
"model_commission": sum(e.get("commission") or 0.0 for e in estimates) if estimates else None,
|
|
122
|
+
"model_spread": sum(e.get("spread") or 0.0 for e in estimates) if estimates else None,
|
|
123
|
+
# Named, so a modelled number that omits a component is not read as the whole
|
|
124
|
+
# cost. Impact needs the day's volume, which the trade clock does not have.
|
|
125
|
+
"model_excludes": excluded,
|
|
126
|
+
"n_fees_reported": len(fees),
|
|
127
|
+
"broker_fees": sum(fees) if fees else None,
|
|
128
|
+
# The distinction the operator has to see: a paper venue reports no fees, so
|
|
129
|
+
# "no fees" must never be presented as "fees were zero".
|
|
130
|
+
"fees_reported": bool(fees),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decline_summary(declines: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
|
135
|
+
"""How often each *kind* of refusal stopped a signal, worst first.
|
|
136
|
+
|
|
137
|
+
Grouped by the decision's reason family, not its message. Rows written before
|
|
138
|
+
decisions carried a code are recognised by their message and folded into the same
|
|
139
|
+
family, because an append-only ledger keeps its history and a report that groups
|
|
140
|
+
only new rows shows one throttle as two. The message embeds the
|
|
141
|
+
numbers that caused it - "gross exposure capped: $7,617.12 of $7,200.00" - so
|
|
142
|
+
counting messages turned sixteen refusals of one kind into sixteen rows of one,
|
|
143
|
+
which is the shape that hides a throttle rather than showing it.
|
|
144
|
+
|
|
145
|
+
One example message is kept per family, because the code alone does not say what
|
|
146
|
+
the limit was or how far over it the book had got.
|
|
147
|
+
"""
|
|
148
|
+
from quantred.execution.decision import reason_family
|
|
149
|
+
|
|
150
|
+
families: Dict[str, Dict[str, Any]] = {}
|
|
151
|
+
for record in declines:
|
|
152
|
+
code = reason_family(record)
|
|
153
|
+
family = families.setdefault(code, {"count": 0, "example": None})
|
|
154
|
+
family["count"] += 1
|
|
155
|
+
if family["example"] is None:
|
|
156
|
+
family["example"] = record.get("reason")
|
|
157
|
+
return dict(sorted(families.items(), key=lambda kv: -kv[1]["count"]))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def execution_report(
|
|
161
|
+
rows: List[Dict[str, Any]],
|
|
162
|
+
declines: Optional[List[Dict[str, Any]]] = None,
|
|
163
|
+
flattens: Optional[List[Dict[str, Any]]] = None,
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""Everything the ledger can say about how well this book was executed.
|
|
166
|
+
|
|
167
|
+
``flattens`` are carried beside the orders rather than folded into them: a position
|
|
168
|
+
an operator closed at the broker never produced a fill this file saw, so it has no
|
|
169
|
+
price, no latency and no slippage. Counting it among the fills would put a hole in
|
|
170
|
+
every average; leaving it out entirely would lose the reason a position vanished.
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
"flattens": list(flattens or []),
|
|
174
|
+
"slippage": slippage_summary(rows),
|
|
175
|
+
"latency": latency_summary(rows),
|
|
176
|
+
"fills": fill_summary(rows),
|
|
177
|
+
"costs": cost_summary(rows),
|
|
178
|
+
"declines": decline_summary(declines or []),
|
|
179
|
+
"orders": rows,
|
|
180
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Choosing a directional cap from what a strategy actually ran at.
|
|
2
|
+
|
|
3
|
+
`max_net_exposure` is a fraction, and picking one from anything but the strategy's own
|
|
4
|
+
history is a guess dressed as a limit. This turns the exposure the backtest carried into
|
|
5
|
+
the only thing that can honestly inform that choice: a distribution, and — for any cap
|
|
6
|
+
you are considering — how often it would have bound.
|
|
7
|
+
|
|
8
|
+
The framing matters more than the arithmetic. **Any cap below the observed maximum would
|
|
9
|
+
have changed the book that was validated.** A cap is not free just because it is loose:
|
|
10
|
+
it either never binds, in which case it documents an intent rather than enforcing one, or
|
|
11
|
+
it binds, in which case the thing running is no longer the thing that was tested. This
|
|
12
|
+
reports both halves and picks nothing.
|
|
13
|
+
|
|
14
|
+
Research clock: reads a completed backtest, decides nothing about a live run.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
#: Caps worth showing, as multiples of the observed p95. Below 1.0 bites into normal
|
|
20
|
+
#: operation; above ~1.5 is loose enough that it only catches an excursion.
|
|
21
|
+
_HEADROOMS = (1.0, 1.25, 1.5)
|
|
22
|
+
|
|
23
|
+
#: A little above the observed maximum, so there is always one candidate that provably
|
|
24
|
+
#: never binds. Without it a fat-tailed book offers only caps that would have changed it.
|
|
25
|
+
_MAX_HEADROOM = 1.1
|
|
26
|
+
|
|
27
|
+
#: Below this many samples a percentile is an anecdote. The derivation still runs — the
|
|
28
|
+
#: numbers are real — but the caveat leads, because a p95 from a handful of steps is the
|
|
29
|
+
#: kind of number that gets quoted as though it were evidence.
|
|
30
|
+
MIN_SAMPLES = 30
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def candidate_caps(stats: Dict[str, float]) -> List[float]:
|
|
34
|
+
"""The caps worth evaluating, derived from the observed distribution alone.
|
|
35
|
+
|
|
36
|
+
The max-based anchor guarantees at least one cap that provably never bound. A
|
|
37
|
+
percentile-only ladder can sit entirely below a fat tail, and then reports that no
|
|
38
|
+
cap leaves the book intact when one obviously does.
|
|
39
|
+
"""
|
|
40
|
+
if not stats or stats.get("max", 0.0) <= 0:
|
|
41
|
+
# A book that never carried a measurable tilt. Every positive cap is unbinding,
|
|
42
|
+
# so offering candidates would invent a choice that does not exist.
|
|
43
|
+
return []
|
|
44
|
+
caps = {round(stats["p95"] * headroom, 4) for headroom in _HEADROOMS}
|
|
45
|
+
caps.add(round(stats["max"] * _MAX_HEADROOM, 4))
|
|
46
|
+
return sorted(cap for cap in caps if cap > 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def net_cap_candidates(exposure: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
50
|
+
"""Caps worth considering, each with what it would have done historically.
|
|
51
|
+
|
|
52
|
+
Binding rates are computed where the samples live — during the backtest — and
|
|
53
|
+
carried here as scalars. Keeping the full series on the result would put one row per
|
|
54
|
+
step into anything that serializes it, for a number only ever read at a handful of
|
|
55
|
+
caps.
|
|
56
|
+
"""
|
|
57
|
+
return list((exposure or {}).get("candidates") or [])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def derive_net_cap(exposure: Dict[str, Any], gross_cap: Optional[float] = None) -> Dict[str, Any]:
|
|
61
|
+
"""What the history says about a directional cap, and what it does not.
|
|
62
|
+
|
|
63
|
+
``recommended`` is the smallest candidate that would never have bound — the only
|
|
64
|
+
choice that leaves the validated book intact. It is a starting point, not a verdict:
|
|
65
|
+
a cap that never binds is documentation, and tightening it is a deliberate decision
|
|
66
|
+
to trade something different from what was tested.
|
|
67
|
+
"""
|
|
68
|
+
stats = (exposure or {}).get("net_abs")
|
|
69
|
+
if not stats:
|
|
70
|
+
return {"available": False, "reason": "no exposure was sampled"}
|
|
71
|
+
|
|
72
|
+
candidates = net_cap_candidates(exposure)
|
|
73
|
+
never_binds = [c for c in candidates if c["binding_rate"] == 0.0]
|
|
74
|
+
# The cap itself, not the candidate that carries it. Holding the dict here and
|
|
75
|
+
# unwrapping it only at the return meant the same name was a dict on one line and a
|
|
76
|
+
# float three lines later, which is how the comparison below came to be a TypeError.
|
|
77
|
+
recommended = min(never_binds, key=lambda candidate: candidate["cap"])["cap"] if never_binds else None
|
|
78
|
+
samples = exposure.get("samples", 0)
|
|
79
|
+
# |long - short| <= long + short identically, so a net cap at or above the gross cap
|
|
80
|
+
# can never bind. Worth saying out loud: a recommendation the gross cap already
|
|
81
|
+
# subsumes is documentation, and reads as a second limit when it is not one.
|
|
82
|
+
subsumed = gross_cap is not None and recommended is not None and recommended >= gross_cap
|
|
83
|
+
return {
|
|
84
|
+
"available": True,
|
|
85
|
+
"samples": samples,
|
|
86
|
+
"thin": samples < MIN_SAMPLES,
|
|
87
|
+
"no_tilt_carried": stats["max"] <= 0,
|
|
88
|
+
"observed": stats,
|
|
89
|
+
"signed_mean": exposure.get("net_signed_mean"),
|
|
90
|
+
"gross_max": exposure.get("gross_max"),
|
|
91
|
+
"candidates": candidates,
|
|
92
|
+
"gross_cap": gross_cap,
|
|
93
|
+
"subsumed_by_gross": subsumed,
|
|
94
|
+
# None when every candidate would have bound: the honest answer is then "no cap
|
|
95
|
+
# derived from this history leaves it unchanged", not the least-bad number.
|
|
96
|
+
"recommended": recommended,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def format_net_cap(derivation: Dict[str, Any]) -> List[str]:
|
|
101
|
+
"""Render the derivation, leading with what it cannot tell you."""
|
|
102
|
+
if not derivation.get("available"):
|
|
103
|
+
return [f" Net exposure: not derivable — {derivation.get('reason', 'no data')}."]
|
|
104
|
+
|
|
105
|
+
observed, lines = derivation["observed"], []
|
|
106
|
+
lines.append(f"\n=== Directional tilt actually carried ({derivation['samples']} steps) ===")
|
|
107
|
+
if derivation.get("thin"):
|
|
108
|
+
# Leads, because a percentile from a handful of steps is exactly the kind of
|
|
109
|
+
# number that gets quoted as evidence once it is written down.
|
|
110
|
+
lines.append(
|
|
111
|
+
f" NOT ENOUGH HISTORY — {derivation['samples']} steps is too few for a "
|
|
112
|
+
f"percentile to mean anything. Read the max; ignore the rest."
|
|
113
|
+
)
|
|
114
|
+
lines.append(
|
|
115
|
+
f" |net| / equity median {observed['median']:.1%} p90 {observed['p90']:.1%} "
|
|
116
|
+
f"p95 {observed['p95']:.1%} p99 {observed['p99']:.1%} max {observed['max']:.1%}"
|
|
117
|
+
)
|
|
118
|
+
signed = derivation.get("signed_mean")
|
|
119
|
+
if signed is not None:
|
|
120
|
+
lean = "long" if signed > 0 else "short"
|
|
121
|
+
lines.append(
|
|
122
|
+
f" signed mean {signed:+.1%} — the book leans {lean} by construction"
|
|
123
|
+
if abs(signed) > 0.01
|
|
124
|
+
else f" signed mean {signed:+.1%} — no systematic lean"
|
|
125
|
+
)
|
|
126
|
+
if derivation.get("gross_max") is not None:
|
|
127
|
+
lines.append(f" gross max {derivation['gross_max']:.1%} of equity")
|
|
128
|
+
|
|
129
|
+
if derivation.get("no_tilt_carried"):
|
|
130
|
+
lines.append(
|
|
131
|
+
"\n This book never carried a measurable tilt, so every positive cap is "
|
|
132
|
+
"unbinding.\n A cap here documents an intent; it does not constrain "
|
|
133
|
+
"anything this history shows."
|
|
134
|
+
)
|
|
135
|
+
return lines
|
|
136
|
+
|
|
137
|
+
lines.append("\n A cap and what it would have done:")
|
|
138
|
+
for candidate in derivation["candidates"]:
|
|
139
|
+
rate = candidate["binding_rate"]
|
|
140
|
+
effect = (
|
|
141
|
+
"never binds — documents the intent, enforces nothing new"
|
|
142
|
+
if rate == 0.0
|
|
143
|
+
else f"would have bound on {rate:.1%} of steps — a different book from the validated one"
|
|
144
|
+
)
|
|
145
|
+
lines.append(f" --max-net-exposure {candidate['cap']:.2f} {effect}")
|
|
146
|
+
|
|
147
|
+
if derivation["recommended"] is not None:
|
|
148
|
+
lines.append(
|
|
149
|
+
f"\n Smallest cap that leaves the validated book intact: {derivation['recommended']:.2f}"
|
|
150
|
+
)
|
|
151
|
+
if derivation.get("subsumed_by_gross"):
|
|
152
|
+
lines.append(
|
|
153
|
+
f" But |net| never exceeds gross, so a gross cap of "
|
|
154
|
+
f"{derivation['gross_cap']:.2f} already holds net below that. This value\n"
|
|
155
|
+
f" documents an intent; it cannot bind while that gross cap stands."
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
lines.append(
|
|
159
|
+
"\n No candidate leaves the validated book intact. Any cap from this "
|
|
160
|
+
"history would have changed what was tested — decide deliberately."
|
|
161
|
+
)
|
|
162
|
+
lines.append(
|
|
163
|
+
" This is a starting point, not a verdict. A cap that never binds is "
|
|
164
|
+
"documentation;\n tightening it is a decision to trade something other than "
|
|
165
|
+
"what was validated."
|
|
166
|
+
)
|
|
167
|
+
return lines
|