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