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,172 @@
1
+ """Data-client construction and universe resolution (the read-only data path).
2
+
3
+ The MCP server and research agent must be *structurally* incapable
4
+ of trading, so this builds **only** a :class:`MarketDataClient` over Alpaca's
5
+ historical-data client - never a ``TradingClient`` or broker. That absence is the
6
+ safety model.
7
+ """
8
+
9
+ import logging
10
+ from datetime import datetime
11
+ from typing import Any, List, Optional, Tuple
12
+
13
+ from quantred.marketdata.base import MarketDataProvider
14
+ from quantred.marketdata.client import MarketDataClient
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def build_data_client(
20
+ cache: bool = False,
21
+ offline: bool = False,
22
+ cache_dir: Optional[Any] = None,
23
+ feed: Optional[str] = None,
24
+ ) -> MarketDataClient:
25
+ """Construct the Alpaca-backed historical data client from settings.
26
+
27
+ Deliberately constructs no trading client / broker: the
28
+ process that calls this cannot place orders.
29
+
30
+ ``cache``/``offline`` opt into the persistent bar cache
31
+ (:class:`~quantred.store.bars.CachedMarketData`): ``cache`` wraps the Alpaca
32
+ provider so repeated requests reuse previously-fetched bars; ``offline``
33
+ additionally forbids any network call (a request touching an uncached range
34
+ raises rather than falling through to Alpaca), and implies ``cache`` on its
35
+ own and needs no credentials. Default behavior (neither flag) is a plain Alpaca
36
+ provider.
37
+
38
+ ``feed`` pins the Alpaca market-data feed; ``None`` (the default) resolves from
39
+ ``ALPACA_DATA_FEED`` and otherwise leaves the SDK's defaults alone.
40
+ """
41
+ from quantred.brokers.alpaca.factory import build_market_data
42
+ from quantred.settings import data_feed, load_settings
43
+
44
+ if offline:
45
+ from quantred.store.bars import CachedMarketData
46
+
47
+ # A cache-only run never reaches the vendor, so it needs no keys: demanding
48
+ # them hid "these bars are not cached" behind a credentials wall, and a
49
+ # warmed cache was unusable on a machine without them.
50
+ return MarketDataClient(CachedMarketData(_NoVendor(), cache_dir=cache_dir, offline=True))
51
+ settings = load_settings()
52
+ provider = build_market_data(settings.alpaca_key, settings.alpaca_secret, feed=feed or data_feed())
53
+ if cache:
54
+ from quantred.store.bars import CachedMarketData
55
+
56
+ provider = CachedMarketData(provider, cache_dir=cache_dir)
57
+ return MarketDataClient(provider)
58
+
59
+
60
+ class _NoVendor(MarketDataProvider):
61
+ """The upstream of a cache-only client: there is no vendor behind it.
62
+
63
+ ``CachedMarketData(offline=True)`` refuses an uncached read before it would ask its
64
+ upstream, so nothing should reach this; if something does, it fails loudly rather
65
+ than becoming a network call nobody asked for.
66
+ """
67
+
68
+ def get_bars(self, symbols, timeframe, start, end):
69
+ raise RuntimeError("--offline: an uncached read reached the vendor")
70
+
71
+ async def stream_bars(self, symbols, handler):
72
+ raise RuntimeError("--offline: live streaming needs a network connection")
73
+
74
+ def supports_streaming(self) -> bool:
75
+ return False
76
+
77
+
78
+ def vintage_stamp(data_client, universe: List[str], timeframe: Any, start: Any, end: Any) -> Optional[str]:
79
+ """The bar cache's data-vintage stamp for this exact fetch, or ``None`` when the
80
+ client is not cache-backed.
81
+
82
+ Part of a run's dedup identity whenever the cache is in play, so a run over bars
83
+ that have since changed is not answered from the old result. One definition for
84
+ both surfaces: the CLI folded it and the service did not, so once the MCP server
85
+ could read the cache (`mcp --offline`) the same run over the two surfaces could never
86
+ match and journaled twice. Calling it ensures the window is cached (it warms the
87
+ cache as a side effect), which is why callers compute it once, up front.
88
+ """
89
+ from quantred.store.bars import CachedMarketData
90
+
91
+ provider = getattr(data_client, "provider", None)
92
+ if not isinstance(provider, CachedMarketData):
93
+ return None
94
+ return provider.vintage_stamp(universe, timeframe, start, end)
95
+
96
+
97
+ #: ``(symbols, timeframe, start, end)``: the arguments of one ``get_bars`` call.
98
+ BarRead = Tuple[List[str], Any, datetime, datetime]
99
+
100
+
101
+ def resolve_universe(
102
+ data_client: MarketDataClient,
103
+ scanner_name: Optional[str],
104
+ candidates: List[str],
105
+ as_of: Optional[datetime] = None,
106
+ *,
107
+ then_reads: Optional[List[BarRead]] = None,
108
+ ) -> List[str]:
109
+ """Filter ``candidates`` through a scanner, falling back to them if none flag.
110
+
111
+ ``then_reads`` lists the reads the caller makes next, written as if the scanner kept
112
+ every candidate. Offline, when the scan itself cannot run from the cache, the miss
113
+ covers that read too, so one warm fixes the command rather than one for the scan
114
+ and a second for the run.
115
+ """
116
+ if not scanner_name or scanner_name == "none":
117
+ return candidates
118
+
119
+ from quantred.scanners.symbol_scanner import SymbolScanner
120
+
121
+ scanner = SymbolScanner(data_client, scanner_name)
122
+ if then_reads is not None:
123
+ scan_tf, scan_start, scan_end = scanner.scan_window(as_of)
124
+ refuse_uncached_together(
125
+ data_client,
126
+ (candidates, scan_tf, scan_start, scan_end),
127
+ list(then_reads),
128
+ why=(
129
+ "The scanner picks the universe from these bars, so which names the run keeps "
130
+ "is not known yet; the command covers the scan and the run for every candidate."
131
+ ),
132
+ )
133
+ flagged = scanner.scan(candidates, as_of=as_of)
134
+ universe = [symbol for symbol, _ in flagged]
135
+ if not universe:
136
+ logger.warning("Scanner flagged no symbols; falling back to the candidate list")
137
+ return candidates
138
+ logger.info("Trading universe from scanner: %s", universe)
139
+ return universe
140
+
141
+
142
+ def refuse_uncached_together(
143
+ data_client: MarketDataClient, first: BarRead, rest: List[BarRead], *, why: Optional[str] = None
144
+ ) -> None:
145
+ """Offline, when ``first`` cannot be served from the cache, raise one miss for it and ``rest``.
146
+
147
+ A command that reads in stages otherwise learns of each stage only after warming
148
+ the one before, a rerun per stage. Nothing is raised while ``first`` is cached,
149
+ because whether the later reads happen at all can turn on it — a scan picks the
150
+ names, a memoized result reads nothing more — and asking for them then would warm
151
+ data the run may never touch; their own read refuses if it comes to that. ``why``
152
+ is printed only when ``rest`` widened the miss.
153
+ """
154
+ from quantred.marketdata.timeframe import Timeframe
155
+ from quantred.store.bars import CachedMarketData
156
+
157
+ provider = getattr(data_client, "provider", None)
158
+ if not isinstance(provider, CachedMarketData) or not provider.offline:
159
+ return
160
+ if provider.is_cached(*first):
161
+ return
162
+
163
+ def key(timeframe: Any) -> str:
164
+ return str(Timeframe.parse(timeframe) if isinstance(timeframe, str) else timeframe)
165
+
166
+ def adds(read: BarRead) -> bool:
167
+ symbols, _, start, end = read
168
+ return bool(set(symbols) - set(first[0])) or start < first[2] or end > first[3]
169
+
170
+ # A warm command takes one timeframe, so a read on another is left to its own miss.
171
+ widened = [r for r in rest if key(r[1]) == key(first[1]) and adds(r) and not provider.is_cached(*r)]
172
+ provider.refuse_uncached_reads([first, *widened], why=why if widened else None)
@@ -0,0 +1,206 @@
1
+ """What a validated contract would try to do, without trying it.
2
+
3
+ The decision path and the execution path answer different questions, and running them
4
+ together is what forced a $200-300 position ceiling onto a book that wanted far more:
5
+ fills were needed to observe anything, so the caps were shrunk until fills happened, and
6
+ the resulting sample was biased toward low-priced names. The strategy under observation
7
+ was not the strategy that was validated.
8
+
9
+ A dry run answers the first question alone. It drives the real decision path - the same
10
+ ``LiveTrader``, the same guards, the same sizing - against a stated account, with the
11
+ caps exactly as configured, and reports what *would* have happened. There are no fills,
12
+ no slippage and no fees here, because a broker that cannot trade produces none, and
13
+ inventing them would recreate the confusion this separation exists to end.
14
+
15
+ **Nothing is journaled.** A dry run measures nothing: no fills, no realized P&L, no
16
+ observed costs, no return series. Recording it would spend a family's statistical budget
17
+ on a rehearsal, which is the same reason a screen journals nothing.
18
+
19
+ **The vocabulary is deliberate.** ``would_submit``, ``would_skip``, ``would_bind`` -
20
+ never *submitted*, *filled* or *blocked*. A report that borrows execution's words will
21
+ be read as execution's evidence, and the one thing this mode must never be mistaken for
22
+ is the small-real session that produces the real thing.
23
+ """
24
+
25
+ from typing import Any, Dict, List, Optional, Sequence
26
+
27
+ from quantred.brokers.dryrun import BANNER, DryRunBroker
28
+ from quantred.execution import decision as decisions
29
+
30
+ #: Refusal families that mean *a configured cap bound*, as opposed to some other reason
31
+ #: an order did not go. Only these are reported as ``would_bind``: calling a market-hours
32
+ #: skip a "bind" would attribute to the book a limit that the clock imposed.
33
+ _BINDING_CODES = frozenset(
34
+ {
35
+ decisions.BOOK_FULL,
36
+ decisions.GROSS_EXPOSURE,
37
+ decisions.NET_EXPOSURE,
38
+ decisions.RISK_BUDGET,
39
+ # A declared floor is a configured limit refusing an order, which is what this
40
+ # bucket means. It reads as one only once the live path actually applies it —
41
+ # before that the floor was printed by the preflight and enforced by nothing.
42
+ #
43
+ # Its sibling, a size rounding to zero, deliberately stays a skip: that is share
44
+ # granularity rather than a limit anybody configured. Both carry a reason code
45
+ # so either can be counted, which is what makes a book being traded below the
46
+ # size it was validated at visible as a number.
47
+ decisions.BELOW_MIN_NOTIONAL,
48
+ }
49
+ )
50
+
51
+
52
+ def resolve_capital(
53
+ config_capital: Optional[float] = None, explicit: Optional[float] = None
54
+ ) -> "tuple[float, str]":
55
+ """The capital a dry run reports against, or a refusal.
56
+
57
+ Config first, then an explicit override, and **no third source**. There is
58
+ deliberately no fallback to a broker's equity and no default account size: the caps
59
+ a dry run reports are only meaningful against the capital they bound, so inventing
60
+ one would answer a question about a book nobody chose - which is the exact failure
61
+ this mode exists to remove, arriving by a different route.
62
+ """
63
+ capital = explicit if explicit is not None else config_capital
64
+ source = "--capital" if explicit is not None else "from config"
65
+ if capital is None:
66
+ raise ValueError(
67
+ "a dry run needs a stated capital. Promote a config that records one, or "
68
+ "pass --capital. There is no default: the caps this reports are only "
69
+ "meaningful against the capital they bound"
70
+ )
71
+ if float(capital) <= 0:
72
+ raise ValueError(f"a dry run needs a positive capital, not {capital}")
73
+ # Returned with its source, because a reader judging whether the caps are the right
74
+ # ones needs to know whether the number came from the validated config or was typed
75
+ # at the prompt. The two are different claims about what is being rehearsed.
76
+ return float(capital), source
77
+
78
+
79
+ def classify(decision: decisions.Decision, refusal_marker: str) -> str:
80
+ """Which bucket one decision belongs to.
81
+
82
+ ``would_submit`` is a decision that reached the broker and was refused *by the dry
83
+ run itself* - it got all the way to submission, so it is an order that would have
84
+ been sent. The plan it carries is what would have been sent, and it exists because
85
+ ``LiveTrader`` builds the plan before submitting for exactly this reason.
86
+
87
+ ``would_bind`` is a configured cap refusing it. ``would_skip`` is everything else:
88
+ no signal, market closed, a position already open. The distinction matters because
89
+ only the middle one is a statement about the book's shape.
90
+ """
91
+ if decision.allowed:
92
+ # Unreachable through a dry-run broker, which refuses before any decision can
93
+ # be allowed. Bucketed anyway rather than dropped: a decision that *was* allowed
94
+ # in a mode that cannot trade is a defect, and losing it would hide one.
95
+ return "would_submit"
96
+ if refusal_marker in (decision.reason or ""):
97
+ return "would_submit"
98
+ if decision.reason_code in _BINDING_CODES:
99
+ return "would_bind"
100
+ return "would_skip"
101
+
102
+
103
+ def dry_run_report(
104
+ decisions_seen: Sequence[decisions.Decision],
105
+ *,
106
+ capital: float,
107
+ capital_source: str,
108
+ positions_source: str,
109
+ universe: Sequence[str],
110
+ unable_to_evaluate: Optional[Sequence[Dict[str, Any]]] = None,
111
+ as_of: Optional[Any] = None,
112
+ refusal_marker: str = "this is a dry run",
113
+ ) -> Dict[str, Any]:
114
+ """The decision path as a report. No fills, no costs, no verdict.
115
+
116
+ Every bucket is reported even when empty, because "no signal bound a cap" and "the
117
+ caps were never reached" are different findings and silence would render them alike.
118
+ """
119
+ buckets: Dict[str, List[Dict[str, Any]]] = {
120
+ "would_submit": [],
121
+ "would_bind": [],
122
+ "would_skip": [],
123
+ # A symbol no decision could be made about is not a skip. A skip is an outcome
124
+ # the strategy reached; this is the absence of one, and folding the two together
125
+ # would report a universe as fully evaluated when part of it was never asked.
126
+ "unable_to_evaluate": list(unable_to_evaluate or []),
127
+ }
128
+ for decision in decisions_seen:
129
+ bucket = classify(decision, refusal_marker)
130
+ row = {
131
+ "symbol": decision.symbol,
132
+ "signal": decision.signal,
133
+ # A `would_submit` row reached the broker and was refused *by this mode*,
134
+ # which is the mode working. Reporting "broker refused: this is a dry run"
135
+ # would describe the mechanism as a failure of the one thing that succeeded,
136
+ # so the outcome is stated and the mechanism stays internal.
137
+ "reason": "would submit" if bucket == "would_submit" else decision.reason,
138
+ "reason_code": None if bucket == "would_submit" else decision.reason_code,
139
+ "guards_consulted": list(decision.guards_consulted),
140
+ }
141
+ plan = decision.plan
142
+ if plan is not None:
143
+ # What would have been sent, in full. Named `plan` rather than `order`: an
144
+ # order is a thing a venue has; this is a thing nobody sent.
145
+ row["plan"] = {
146
+ "side": plan.side,
147
+ "qty": plan.qty,
148
+ "reference_price": plan.reference_price,
149
+ "stop_loss": plan.stop_loss,
150
+ "take_profit": plan.take_profit,
151
+ "cost_estimate": plan.cost_estimate,
152
+ }
153
+ buckets[bucket].append(row)
154
+
155
+ evaluated = len(decisions_seen)
156
+ return {
157
+ "mode": "dry_run",
158
+ "banner": BANNER,
159
+ # Stated rather than discovered, and named as such, so no reader takes it for a
160
+ # balance read off an account.
161
+ "capital": capital,
162
+ "capital_source": capital_source,
163
+ "starting_positions": positions_source,
164
+ "universe": list(universe),
165
+ "as_of": as_of,
166
+ **buckets,
167
+ "counts": {name: len(rows) for name, rows in buckets.items()},
168
+ "n_decisions": evaluated,
169
+ # Reported beside the universe size so a shortfall is visible in the summary and
170
+ # not only in a section somebody has to scroll to. A symbol that vanished from
171
+ # the detail is a symbol nobody notices was never asked.
172
+ "n_evaluated": evaluated,
173
+ "n_unable_to_evaluate": len(buckets["unable_to_evaluate"]),
174
+ # Said explicitly rather than left to be inferred from an absent section. A
175
+ # reader looking for slippage should find out here that this mode cannot produce
176
+ # it, not conclude the run was clean.
177
+ "not_covered": (
178
+ "fills, slippage, broker fees, queueing, and paper/live account effects. "
179
+ "A dry run submits nothing, so it observes none of them — that is what a "
180
+ "small-real session is for"
181
+ ),
182
+ "journaled": False,
183
+ }
184
+
185
+
186
+ def build_broker(
187
+ *,
188
+ capital: float,
189
+ positions: Optional[List[Any]] = None,
190
+ positions_source: Optional[str] = None,
191
+ tradable: Optional[Dict[str, bool]] = None,
192
+ market_open: bool = True,
193
+ ) -> DryRunBroker:
194
+ """A dry-run broker, with the source of its starting book recorded.
195
+
196
+ ``positions_source`` defaults to describing what was actually supplied, so an empty
197
+ book is never ambiguous between "start flat" and "nobody said".
198
+ """
199
+ source = positions_source or ("adopted" if positions else "flat")
200
+ return DryRunBroker(
201
+ capital=capital,
202
+ positions=positions,
203
+ positions_source=source,
204
+ tradable=tradable,
205
+ market_open=market_open,
206
+ )
@@ -0,0 +1,130 @@
1
+ """Metric glossary.
2
+
3
+ Definitions + pitfalls for every metric the analytics layer reports, written for
4
+ an LLM reader so it doesn't over-trust in-sample numbers. Surfaces the two traps
5
+ that matter most: the equity-curve fidelity caveat and the
6
+ multiple-testing correction the Deflated Sharpe applies.
7
+ """
8
+
9
+ from typing import Any, Dict, Iterable
10
+
11
+ from quantred.analytics.performance import FLAG_KEYS, METRIC_KEYS
12
+
13
+ #: Cross-cutting caveats the agent should keep in mind for *all* metrics.
14
+ GLOBAL_CAVEATS = [
15
+ "The equity basis depends on the result. A backtest, and each walk-forward fold, is "
16
+ "marked to market at every bar's close, so a loss inside an open position counts - "
17
+ "but only as of bar closes; a fall and recovery within one bar is not seen. A "
18
+ "walk-forward's OOS AGGREGATE across folds is rebuilt from CLOSED-trade P&L resampled "
19
+ "to daily, so its max_drawdown / ulcer_index / volatility UNDERSTATE risk during long "
20
+ "holds.",
21
+ "In-sample metrics from run_optimization are NOT evidence of edge - they are the "
22
+ "by-product of selecting the best of many configs. Validate with run_walk_forward, "
23
+ "which scores out-of-sample and applies the Deflated Sharpe.",
24
+ "Any metric computed on fewer than ~30 trades is low-power (see the low_sample flag). "
25
+ "A great Sharpe on 4 trades is noise.",
26
+ ]
27
+
28
+ _DEFS: Dict[str, str] = {
29
+ "total_return": "Final/initial capital - 1, in %. Period-dependent; use cagr to compare windows of different length.",
30
+ "buy_hold_return": "Average % return of holding the *traded universe* over the window (the baseline to beat). "
31
+ "Not the benchmark - it does not change when --benchmark does; see benchmark_buy_hold_return.",
32
+ "benchmark_buy_hold_return": "% return of holding the benchmark over the same steps the strategy was measured on. "
33
+ "0.0 when no benchmark was supplied.",
34
+ "sharpe_ratio": "Annualized mean/std of returns (rf=0). The headline ratio. Inflated by short samples and fat tails - read probabilistic_sharpe_ratio alongside it.",
35
+ "sortino_ratio": "Like Sharpe but only penalizes downside deviation.",
36
+ "calmar_ratio": "CAGR / max drawdown. Reward per unit of worst peak-to-trough loss.",
37
+ "max_drawdown": "Largest peak-to-trough equity decline, % (positive), at bar-close marks for a backtest "
38
+ "or a fold; UNDERSTATED for a walk-forward OOS aggregate, which uses the closed-trade curve.",
39
+ "total_trades": "Number of closed trades. The sample-size guard; <30 => low_sample.",
40
+ "win_rate": "% of trades with positive P&L. High win rate can still lose money if losers are large (see payoff_ratio).",
41
+ "profit_factor": "Gross profit / gross loss. >1 is profitable; inf means no losing trades (often a tiny sample).",
42
+ "avg_win": "Mean P&L of winning trades ($).",
43
+ "avg_loss": "Mean P&L of losing trades, magnitude ($).",
44
+ "largest_win": "Best single trade ($).",
45
+ "largest_loss": "Worst single trade, magnitude ($).",
46
+ "cagr": "Compound annual growth rate, %. The cross-window comparison number (annualized).",
47
+ "annualized_volatility": "Std of returns scaled to a year, %.",
48
+ "max_drawdown_duration": "Longest run of consecutive periods spent underwater (below a prior peak).",
49
+ "exposure": "% of the window with at least one open position. A Sharpe earned at 5% exposure != one at 95%.",
50
+ "probabilistic_sharpe_ratio": "P(true Sharpe > 0) given sample length, skew and kurtosis. Corrects the Sharpe for short, fat-tailed samples. 0..1.",
51
+ "deflated_sharpe_ratio": "Probabilistic Sharpe against the EXPECTED-BEST Sharpe of n_trials configs. The anti-overfitting metric: the more configs tried, the higher the bar. 0..1; >0.5 is encouraging.",
52
+ "recovery_factor": "Net profit / max drawdown ($). Absolute-dollar cousin of Calmar.",
53
+ "ulcer_index": "RMS drawdown depth (%) - penalizes deep AND long drawdowns. Lower is better.",
54
+ "martin_ratio": "CAGR / ulcer index (Ulcer Performance Index).",
55
+ "sterling_ratio": "CAGR / average drawdown.",
56
+ "var_95": "Historical 95% Value-at-Risk of period returns (%, positive loss).",
57
+ "var_99": "Historical 99% Value-at-Risk (%).",
58
+ "cvar_95": "Expected shortfall: mean loss in the worst 5% tail (%).",
59
+ "expectancy": "Mean P&L per trade ($).",
60
+ "payoff_ratio": "Average win / average loss (magnitudes).",
61
+ "gain_to_pain_ratio": "Sum of P&L / sum of absolute losses.",
62
+ "kelly_criterion": "Theoretical optimal capital fraction from win rate and payoff. Usually scaled down in practice.",
63
+ "sqn": "System Quality Number (Van Tharp): sqrt(n)*mean(pnl)/std(pnl). Expectancy quality adjusted for sample size.",
64
+ "max_consecutive_wins": "Longest winning streak.",
65
+ "max_consecutive_losses": "Longest losing streak - drives position-sizing survivability.",
66
+ "avg_trade_duration": "Mean holding period in hours.",
67
+ "mae_pct": "Average Max Adverse Excursion: worst drawdown DURING the hold, %. Diagnoses stop placement / entry timing.",
68
+ "mfe_pct": "Average Max Favorable Excursion: best runup during the hold, %. Diagnoses exit timing.",
69
+ "alpha": "Per-period OLS intercept of strategy vs benchmark returns (0 if no benchmark supplied).",
70
+ "beta": "OLS slope vs benchmark - market sensitivity (0 if no benchmark).",
71
+ "r_squared": "Fraction of strategy variance explained by the benchmark.",
72
+ "treynor_ratio": "Annualized excess return per unit of beta (needs a benchmark). 0.0 when |beta| is too near "
73
+ "zero to divide by - check treynor_available before reading it as a real zero.",
74
+ "information_ratio": "Annualized active return / tracking error vs benchmark.",
75
+ "skew": "Skewness of returns. Negative => occasional large losses.",
76
+ "kurtosis": "Excess kurtosis. High => fat tails (more extreme moves than normal).",
77
+ "downside_deviation": "Annualized std of negative returns only (%).",
78
+ "tail_ratio": "abs(95th pct) / abs(5th pct) of returns. >1 => right tail dominates.",
79
+ "omega_ratio": "Probability-weighted gains / losses about a threshold.",
80
+ "best_period": "Best single-period return (%).",
81
+ "worst_period": "Worst single-period return (%).",
82
+ "turnover": "Traded notional / capital - a proxy for fee/slippage drag.",
83
+ }
84
+
85
+ _FLAG_DEFS: Dict[str, str] = {
86
+ "benchmark_available": "Whether a benchmark series was supplied; if False, alpha/beta/treynor/information_ratio are 0.",
87
+ "treynor_available": "Whether beta was far enough from zero for Treynor to mean anything. Fails independently of "
88
+ "benchmark_available: a benchmark can be present and the ratio still meaningless for a market-neutral book.",
89
+ "low_sample": "True when total_trades < 30 - treat all ratios with caution.",
90
+ }
91
+
92
+ _PITFALLS: Dict[str, str] = {
93
+ "sharpe_ratio": "Most likely to mislead: high in-sample after optimization is expected, not impressive. Compare to deflated_sharpe_ratio and OOS.",
94
+ "profit_factor": "inf / very high usually means too few losing trades to be meaningful.",
95
+ "max_drawdown": "Bar-close marks miss intrabar lows; a walk-forward OOS aggregate (closed-trade curve) "
96
+ "misses open-position losses entirely, so its true drawdown is worse.",
97
+ "calmar_ratio": "Sensitive to a single drawdown event and to window length.",
98
+ "deflated_sharpe_ratio": "Requires an honest n_trials. The research agent must accumulate trials across the whole session, or this lies.",
99
+ }
100
+
101
+
102
+ def metrics_glossary() -> Dict[str, Any]:
103
+ """Definition + pitfalls of every reported metric, plus global caveats."""
104
+ metrics = {}
105
+ for key in METRIC_KEYS:
106
+ entry: Dict[str, str] = {"definition": _DEFS.get(key, "(no description)")}
107
+ if key in _PITFALLS:
108
+ entry["pitfall"] = _PITFALLS[key]
109
+ metrics[key] = entry
110
+ flags = {key: {"definition": _FLAG_DEFS.get(key, "")} for key in FLAG_KEYS}
111
+ return {"metrics": metrics, "flags": flags, "global_caveats": GLOBAL_CAVEATS}
112
+
113
+
114
+ def definitions_for(keys: Iterable[str]) -> Dict[str, str]:
115
+ """The definition (and pitfall, when there is one) for the named metrics.
116
+
117
+ Lets another surface — a tool description, a report footer — quote what a
118
+ metric means instead of restating it in its own words. Two descriptions of one
119
+ metric will drift; one definition with two readers cannot. Unknown keys are
120
+ skipped rather than rendered as "(no description)", which would advertise a
121
+ typo as a metric.
122
+ """
123
+ out: Dict[str, str] = {}
124
+ for key in keys:
125
+ definition = _DEFS.get(key) or _FLAG_DEFS.get(key)
126
+ if not definition:
127
+ continue
128
+ pitfall = _PITFALLS.get(key)
129
+ out[key] = f"{definition} Pitfall: {pitfall}" if pitfall else definition
130
+ return out