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,353 @@
1
+ """Reading a recorded run's trade table without opening SQLite.
2
+
3
+ Exit-reason P&L, win and loss by reason, how long positions were held, how far they
4
+ went against the book before they worked — the ordinary questions about a run that
5
+ somebody has already paid for. They were ordinary enough to be asked by hand, in SQL,
6
+ against a schema nobody should have to read.
7
+
8
+ Everything here is a **pure function over a stored table** — ``{columns, rows,
9
+ total_rows, truncated}`` — and returns structured data. Nothing here formats anything,
10
+ grades anything, or decides whether a number is good. The register is the one the
11
+ execution report and the P&L-concentration block already set: report the number, say
12
+ what it does not cover, leave the judgement to somebody who has seen a few.
13
+
14
+ Three absences are kept apart, because collapsing any two produces a confident answer
15
+ to a question that was never asked:
16
+
17
+ * **No table.** The run did not opt into keeping one. Nothing is known about its trades
18
+ and no section is computed.
19
+ * **No trades.** The run opted in and closed nothing. Every section is computed and
20
+ every count is zero — a real, measured answer.
21
+ * **No column.** The table is there but does not carry what a section needs. That
22
+ section says which column it wanted and is silent about the rest, rather than
23
+ reporting a distribution over data it did not have.
24
+
25
+ **A truncated table does not get aggregated.** A trade table capped at the storage
26
+ ceiling holds a prefix of a run's trades, so a total over it is not a smaller number
27
+ than the truth, it is a wrong one — and it looks exactly like a right one. Sums refuse
28
+ by default; ``allow_partial=True`` computes them anyway and the result says, in the
29
+ payload rather than in some caller's formatting, that the numbers cover the stored rows
30
+ only. A table whose completeness was never recorded is refused on the same terms: it
31
+ did not claim to be whole, and treating "unknown" as "yes" is the failure this whole
32
+ path exists to close.
33
+ """
34
+
35
+ from datetime import datetime
36
+ from typing import Any, Dict, List, Optional, Sequence
37
+
38
+ import numpy as np
39
+
40
+ from quantred.analytics import metrics
41
+
42
+ #: Aggregate status. ``complete`` — the numbers cover every trade the run made.
43
+ #: ``truncated`` — they cover the stored rows only, and the caller asked for that.
44
+ #: ``unavailable`` — there are no numbers, and ``reason`` says why.
45
+ COMPLETE = "complete"
46
+ TRUNCATED = "truncated"
47
+ UNAVAILABLE = "unavailable"
48
+
49
+ #: What the stored table itself is, which is a different question from what the
50
+ #: aggregate over it is worth. ``unknown`` is not a synonym for ``whole``.
51
+ SOURCE_WHOLE = "whole"
52
+ SOURCE_CAPPED = "capped"
53
+ SOURCE_UNKNOWN = "unknown"
54
+ SOURCE_ABSENT = "not recorded"
55
+
56
+
57
+ def describe_source(table: Optional[Dict[str, Any]]) -> Dict[str, Any]:
58
+ """What the stored table is, before anything is computed from it."""
59
+ if table is None:
60
+ return {
61
+ "recorded": False,
62
+ "completeness": SOURCE_ABSENT,
63
+ "rows_stored": None,
64
+ "total_rows": None,
65
+ "columns": [],
66
+ }
67
+ rows = table.get("rows") or []
68
+ truncated = table.get("truncated")
69
+ completeness = SOURCE_UNKNOWN if truncated is None else (SOURCE_CAPPED if truncated else SOURCE_WHOLE)
70
+ return {
71
+ "recorded": True,
72
+ "completeness": completeness,
73
+ "rows_stored": len(rows),
74
+ "total_rows": table.get("total_rows"),
75
+ "columns": [str(c) for c in (table.get("columns") or [])],
76
+ }
77
+
78
+
79
+ def _columns(table: Dict[str, Any]) -> Dict[str, List[Any]]:
80
+ """The table transposed into ``{column: values}``.
81
+
82
+ Rows shorter than the header are padded with ``None`` rather than dropped: a
83
+ malformed row is one trade with fields missing, and losing it silently would move
84
+ every count in this module.
85
+ """
86
+ names = [str(c) for c in (table.get("columns") or [])]
87
+ rows = table.get("rows") or []
88
+ out: Dict[str, List[Any]] = {name: [] for name in names}
89
+ for row in rows:
90
+ for i, name in enumerate(names):
91
+ out[name].append(row[i] if i < len(row) else None)
92
+ return out
93
+
94
+
95
+ def _floats(values: Sequence[Any]) -> "np.ndarray":
96
+ """A column as floats, with anything unparseable as NaN rather than dropped -
97
+ so the count of what could not be measured survives to be reported."""
98
+ out = np.full(len(values), np.nan, dtype="float64")
99
+ for i, value in enumerate(values):
100
+ try:
101
+ if value is None or isinstance(value, bool):
102
+ continue
103
+ out[i] = float(value)
104
+ except (TypeError, ValueError):
105
+ continue
106
+ return out
107
+
108
+
109
+ def _as_datetime(value: Any) -> Optional[datetime]:
110
+ """One cell as a datetime, or ``None``. Stored rows carry ISO strings (the journal
111
+ is JSON); a live frame carries timestamps."""
112
+ if value is None:
113
+ return None
114
+ if isinstance(value, datetime):
115
+ return value
116
+ try:
117
+ return datetime.fromisoformat(str(value))
118
+ except (TypeError, ValueError):
119
+ return None
120
+
121
+
122
+ def _distribution(values: "np.ndarray", n_total: int) -> Dict[str, Any]:
123
+ """Median/quartiles/extremes of a measured column, with the unmeasured counted.
124
+
125
+ ``n_measured`` leads. A tidy median over three of two hundred values is not a
126
+ description of the run, and the only thing that says so is the count beside it.
127
+ """
128
+ finite = values[np.isfinite(values)]
129
+ summary: Dict[str, Any] = {
130
+ "n_measured": int(finite.size),
131
+ "n_unmeasured": int(n_total - finite.size),
132
+ }
133
+ if finite.size == 0:
134
+ summary.update({k: None for k in ("median", "mean", "p25", "p75", "p90", "min", "max")})
135
+ return summary
136
+ summary.update(
137
+ {
138
+ "median": float(np.median(finite)),
139
+ "mean": float(np.mean(finite)),
140
+ "p25": float(np.percentile(finite, 25)),
141
+ "p75": float(np.percentile(finite, 75)),
142
+ "p90": float(np.percentile(finite, 90)),
143
+ "min": float(np.min(finite)),
144
+ "max": float(np.max(finite)),
145
+ }
146
+ )
147
+ return summary
148
+
149
+
150
+ def _unavailable(requires: Sequence[str], present: Sequence[str]) -> Dict[str, Any]:
151
+ """A section that could not be computed, naming the column it wanted.
152
+
153
+ Not an empty distribution. "No trade lasted any time" and "this table does not
154
+ record when trades opened" are different statements, and only one of them is about
155
+ the strategy.
156
+ """
157
+ missing = [c for c in requires if c not in present]
158
+ return {
159
+ "available": False,
160
+ "requires": list(requires),
161
+ "missing_columns": missing,
162
+ "note": f"not computed: the stored table has no {', '.join(missing)} column",
163
+ }
164
+
165
+
166
+ def _win_loss_over(pnl: "np.ndarray") -> Dict[str, Any]:
167
+ """Wins, losses, and the ratios between them for one set of trades.
168
+
169
+ Rates are ``None`` with nothing to average rather than ``0.0``: a set with no
170
+ losing trades has no average loss, and zero is a measurement.
171
+ """
172
+ finite = pnl[np.isfinite(pnl)]
173
+ wins, losses = finite[finite > 0], finite[finite < 0]
174
+ return {
175
+ "trades": int(finite.size),
176
+ "unmeasured": int(pnl.size - finite.size),
177
+ "wins": int(wins.size),
178
+ "losses": int(losses.size),
179
+ "flat": int(finite.size - wins.size - losses.size),
180
+ "win_rate": float(metrics.win_rate(finite)) if finite.size else None,
181
+ "net_pnl": float(np.sum(finite)) if finite.size else None,
182
+ "gross_profit": float(np.sum(wins)) if wins.size else 0.0,
183
+ "gross_loss": float(np.sum(losses)) if losses.size else 0.0,
184
+ "avg_win": float(np.mean(wins)) if wins.size else None,
185
+ "avg_loss": float(np.mean(losses)) if losses.size else None,
186
+ "largest_win": float(np.max(wins)) if wins.size else None,
187
+ "largest_loss": float(np.min(losses)) if losses.size else None,
188
+ "expectancy": float(metrics.expectancy(finite)) if finite.size else None,
189
+ "profit_factor": float(metrics.profit_factor(finite)) if finite.size else None,
190
+ "payoff_ratio": float(metrics.payoff_ratio(finite)) if finite.size else None,
191
+ }
192
+
193
+
194
+ def exit_reason_breakdown(columns: Dict[str, List[Any]]) -> Dict[str, Any]:
195
+ """Net P&L, win rate and share of trades, per exit reason.
196
+
197
+ A headline return says nothing about where it came from, and a book whose entire
198
+ edge arrives through one exit path is a bet on that path's fill assumption. Nothing
199
+ in the summary metrics distinguishes it from one whose edge is spread across exits,
200
+ so this reports the split and names the concentration when there is one.
201
+ """
202
+ present = list(columns)
203
+ if "exit_reason" not in present or "pnl" not in present:
204
+ return _unavailable(("exit_reason", "pnl"), present)
205
+
206
+ reasons = ["" if r is None else str(r) for r in columns["exit_reason"]]
207
+ pnl = _floats(columns["pnl"])
208
+ n_total = len(reasons)
209
+
210
+ rows: List[Dict[str, Any]] = []
211
+ for reason in sorted(set(reasons)):
212
+ mask = np.array([r == reason for r in reasons], dtype=bool)
213
+ summary = _win_loss_over(pnl[mask])
214
+ summary["exit_reason"] = reason or "(unlabelled)"
215
+ # Two counts, because they answer different questions and sharing one name made
216
+ # them disagree: `rows` is every trade that exited this way, `trades` only those
217
+ # whose P&L could be measured. The share is of `rows` — a trade with an
218
+ # unreadable P&L still exited somehow — so the shares sum to one while the
219
+ # `trades` column may not sum to the total. `unmeasured` is what explains the
220
+ # gap, and a renderer that drops it leaves the two columns contradicting.
221
+ summary["rows"] = int(mask.sum())
222
+ summary["share_of_trades"] = (summary["rows"] / n_total) if n_total else None
223
+ rows.append(summary)
224
+ rows.sort(key=lambda r: (r["net_pnl"] is None, -(r["net_pnl"] or 0.0)))
225
+
226
+ # Concentration is measured over the *gains* only. A loss-making exit does not
227
+ # dilute the claim that one path produced the profit; it is a separate fact.
228
+ gains = {r["exit_reason"]: r["net_pnl"] for r in rows if (r["net_pnl"] or 0.0) > 0}
229
+ concentration = None
230
+ if gains:
231
+ total_gain = sum(gains.values())
232
+ top = max(gains, key=lambda k: gains[k])
233
+ concentration = {
234
+ "exit_reason": top,
235
+ "share_of_gain": gains[top] / total_gain if total_gain else None,
236
+ }
237
+ return {
238
+ "available": True,
239
+ "rows": rows,
240
+ "n_trades": n_total,
241
+ "concentration": concentration,
242
+ }
243
+
244
+
245
+ def duration_summary(columns: Dict[str, List[Any]]) -> Dict[str, Any]:
246
+ """How long positions were held, in days.
247
+
248
+ Held time is the bridge between a per-trade number and a portfolio one: the same
249
+ per-trade excursion means something different when the average position is open a
250
+ day than when it is open a month.
251
+ """
252
+ present = list(columns)
253
+ if "entry_time" not in present or "exit_time" not in present:
254
+ return _unavailable(("entry_time", "exit_time"), present)
255
+
256
+ entries = [_as_datetime(v) for v in columns["entry_time"]]
257
+ exits = [_as_datetime(v) for v in columns["exit_time"]]
258
+ days = np.full(len(entries), np.nan, dtype="float64")
259
+ for i, (opened, closed) in enumerate(zip(entries, exits)):
260
+ if opened is not None and closed is not None:
261
+ days[i] = (closed - opened).total_seconds() / 86_400.0
262
+ summary = _distribution(days, len(entries))
263
+ summary["available"] = True
264
+ summary["unit"] = "days"
265
+ return summary
266
+
267
+
268
+ def excursion_summary(columns: Dict[str, List[Any]]) -> Dict[str, Any]:
269
+ """How far each position went against, and in favour of, the book while open.
270
+
271
+ Per *trade*, which is the figure recorded — not the book's aggregate open
272
+ drawdown, which is a different question this does not answer and must not be read
273
+ as answering. A position deep underwater that is a small fraction of the book did
274
+ not put the book that far underwater, and reading the first as the second is the
275
+ mistake this label exists to prevent.
276
+ """
277
+ present = list(columns)
278
+ if "mae_pct" not in present and "mfe_pct" not in present:
279
+ return _unavailable(("mae_pct", "mfe_pct"), present)
280
+ out: Dict[str, Any] = {
281
+ "available": True,
282
+ "unit": "percent of entry price, per trade",
283
+ "note": "per-trade excursion — not the book's aggregate open drawdown",
284
+ }
285
+ for name in ("mae_pct", "mfe_pct"):
286
+ if name in present:
287
+ values = _floats(columns[name])
288
+ out[name] = _distribution(values, len(values))
289
+ else:
290
+ out[name] = _unavailable((name,), present)
291
+ return out
292
+
293
+
294
+ def trade_analytics(table: Optional[Dict[str, Any]], *, allow_partial: bool = False) -> Dict[str, Any]:
295
+ """Everything this module can say about one recorded run's trades.
296
+
297
+ ``status`` is the thing to read first. ``complete`` means the sections below cover
298
+ every trade the run made. ``truncated`` means they cover the stored rows only and
299
+ the caller asked for that. ``unavailable`` means there are no sections, and
300
+ ``reason`` says why — no table, or a table whose totals cannot honestly be taken.
301
+ """
302
+ source = describe_source(table)
303
+ result: Dict[str, Any] = {"status": UNAVAILABLE, "reason": "", "source": source}
304
+
305
+ if table is None:
306
+ result["reason"] = (
307
+ "no trade table was recorded for this trial — re-run it with trades recorded "
308
+ "(`--record-trades` on the CLI, `record_trades=true` over MCP). "
309
+ "This is not the same as a run that made no trades"
310
+ )
311
+ return result
312
+
313
+ completeness = source["completeness"]
314
+ if completeness != SOURCE_WHOLE and not allow_partial:
315
+ stored, total = source["rows_stored"], source["total_rows"]
316
+ result["reason"] = (
317
+ (
318
+ f"the stored table holds {stored:,} of the run's {total:,} trades — it was capped "
319
+ "at the storage ceiling, so a total over it would be short by the rest"
320
+ )
321
+ if completeness == SOURCE_CAPPED
322
+ else (
323
+ "whether the stored table holds all of the run's trades was not recorded, so a "
324
+ "total over it cannot be called complete"
325
+ )
326
+ )
327
+ return result
328
+
329
+ columns = _columns(table)
330
+ result.update(
331
+ {
332
+ "status": COMPLETE if completeness == SOURCE_WHOLE else TRUNCATED,
333
+ "n_trades": source["rows_stored"],
334
+ "overall": (
335
+ _win_loss_over(_floats(columns["pnl"]))
336
+ if "pnl" in columns
337
+ else _unavailable(("pnl",), list(columns))
338
+ ),
339
+ "exit_reasons": exit_reason_breakdown(columns),
340
+ "duration": duration_summary(columns),
341
+ "excursion": excursion_summary(columns),
342
+ }
343
+ )
344
+ if result["status"] == TRUNCATED:
345
+ if completeness == SOURCE_CAPPED:
346
+ of_total = f" of the run's {source['total_rows']:,}" if source["total_rows"] else ""
347
+ result["reason"] = (
348
+ f"computed over the {source['rows_stored']:,} stored rows only{of_total} — every "
349
+ "count and total below is a partial"
350
+ )
351
+ else:
352
+ result["reason"] = "computed over the stored rows, which were not recorded as being all of them"
353
+ return result