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,190 @@
1
+ """Reading a parameter sweep without fooling yourself with the best point.
2
+
3
+ A screen evaluates many configurations and the natural thing to print is the winner.
4
+ That is the one number a sweep cannot support: **the best of N is the maximum of N
5
+ draws**, and the maximum of N draws from pure noise is a positive number that grows
6
+ with N. A leaderboard with no null beside it is a selection-bias machine — the same
7
+ error the Deflated Sharpe exists to prevent, one layer up, with no deflation applied.
8
+
9
+ So the summary leads with the *distribution* — how many points, where the middle sits,
10
+ how wide the spread is, how many were positive at all — and any statement about a best
11
+ point carries what the best of that many draws is worth under the null.
12
+
13
+ The second thing worth reading off a sweep is *shape*. A coherent gradient across a
14
+ parameter is different evidence from a scattered set of positive points: it says the
15
+ result varies with the parameter the way a real effect would, and it can point off the
16
+ edge of the searched space, which a leaderboard never shows.
17
+ """
18
+
19
+ import math
20
+ from typing import Any, Dict, List, Optional, Sequence
21
+
22
+ import numpy as np
23
+
24
+ from quantred.analytics.metrics import expected_max_sharpe
25
+
26
+ #: Objectives whose null hypothesis is zero and whose estimate is roughly symmetric —
27
+ #: the conditions under which "the expected maximum of N draws" is a statement about
28
+ #: noise rather than an arbitrary number.
29
+ #:
30
+ #: Declared here, beside the code that uses it, and deliberately short. A profit factor
31
+ #: is null-centred on 1 and heavily skewed; a total return over a fixed window is
32
+ #: skewed too. Computing this for them would produce a confident figure that means
33
+ #: nothing, which is worse than the honest refusal below — a screen that cannot say
34
+ #: what noise looks like for an objective must say *that*, not guess.
35
+ NULL_CENTRED_OBJECTIVES = frozenset({"sharpe_ratio", "sortino_ratio", "information_ratio"})
36
+
37
+
38
+ def score_distribution(scores: Sequence[float]) -> Dict[str, Any]:
39
+ """Where a sweep's results actually sit, before anything is ranked.
40
+
41
+ ``n_finite`` is reported separately from ``n``: an evaluation that failed or
42
+ produced no trades is not a result of zero, and silently dropping it would make a
43
+ sweep look narrower and more successful than it was.
44
+ """
45
+ values = np.asarray(list(scores), dtype="float64")
46
+ finite = values[np.isfinite(values)]
47
+ summary: Dict[str, Any] = {
48
+ "n": int(values.size),
49
+ "n_finite": int(finite.size),
50
+ "n_dropped": int(values.size - finite.size),
51
+ }
52
+ if finite.size == 0:
53
+ summary.update(
54
+ {
55
+ "median": None,
56
+ "mean": None,
57
+ "std": None,
58
+ "p25": None,
59
+ "p75": None,
60
+ "min": None,
61
+ "max": None,
62
+ "positive_rate": None,
63
+ }
64
+ )
65
+ return summary
66
+ summary.update(
67
+ {
68
+ "median": float(np.median(finite)),
69
+ "mean": float(np.mean(finite)),
70
+ # Sample standard deviation: with one point there is no spread to report,
71
+ # and 0.0 would read as "every result agreed".
72
+ "std": float(np.std(finite, ddof=1)) if finite.size > 1 else None,
73
+ "p25": float(np.percentile(finite, 25)),
74
+ "p75": float(np.percentile(finite, 75)),
75
+ "min": float(np.min(finite)),
76
+ "max": float(np.max(finite)),
77
+ "positive_rate": float(np.mean(finite > 0)),
78
+ }
79
+ )
80
+ return summary
81
+
82
+
83
+ def noise_baseline(scores: Sequence[float], objective: str) -> Dict[str, Any]:
84
+ """What the best of this many draws would be worth if none of them had any edge.
85
+
86
+ The dispersion is taken from the screened results themselves, which is what the
87
+ Deflated Sharpe's own correction asks for: the variance of the estimates across the
88
+ configurations that were tried. Two things about that are worth stating plainly,
89
+ and both are carried in the returned payload rather than left to a reader:
90
+
91
+ * It assumes the draws are **independent**. Neighbouring grid points are not — they
92
+ share most of their parameters and most of their trades — so the effective number
93
+ of independent trials is smaller than ``n``, and this baseline is correspondingly
94
+ *higher* than a correlation-aware one would be.
95
+ * The dispersion is measured on results that may contain real structure, so it is
96
+ not a pure-noise dispersion either.
97
+
98
+ It is a reference point for reading a leaderboard, not a test. ``applicable`` is
99
+ ``False`` — with the reason attached — whenever the objective's null is not zero,
100
+ because a number computed anyway would be quoted as though it meant something.
101
+ """
102
+ values = np.asarray(list(scores), dtype="float64")
103
+ finite = values[np.isfinite(values)]
104
+ payload: Dict[str, Any] = {
105
+ "objective": objective,
106
+ "n_draws": int(finite.size),
107
+ "applicable": False,
108
+ "reason": "",
109
+ "dispersion": None,
110
+ "expected_best_under_null": None,
111
+ "observed_best": float(np.max(finite)) if finite.size else None,
112
+ "assumes_independence": True,
113
+ }
114
+ if objective not in NULL_CENTRED_OBJECTIVES:
115
+ payload["reason"] = (
116
+ f"no null baseline for {objective}: the maximum of N draws is only "
117
+ f"interpretable for an objective whose null is zero and roughly symmetric "
118
+ f"({', '.join(sorted(NULL_CENTRED_OBJECTIVES))}). The best point here is "
119
+ f"still the best of {int(finite.size)} draws — nothing here says what that "
120
+ f"is worth."
121
+ )
122
+ return payload
123
+ if finite.size < 2:
124
+ payload["reason"] = "fewer than two evaluated points: nothing to measure a spread from"
125
+ return payload
126
+ variance = float(np.var(finite, ddof=1))
127
+ if variance <= 0:
128
+ payload["reason"] = "every evaluated point scored identically, so there is no spread to draw from"
129
+ return payload
130
+ payload.update(
131
+ {
132
+ "applicable": True,
133
+ "dispersion": math.sqrt(variance),
134
+ "expected_best_under_null": float(expected_max_sharpe(variance, int(finite.size))),
135
+ }
136
+ )
137
+ return payload
138
+
139
+
140
+ def parameter_gradient(
141
+ rows: Sequence[Dict[str, Any]], parameter: str, objective: str
142
+ ) -> Optional[List[Dict[str, Any]]]:
143
+ """How the objective behaves across one parameter's values.
144
+
145
+ The question a leaderboard cannot answer. A positive rate that falls monotonically
146
+ as a filter tightens is structure; the same number of positive points scattered at
147
+ random across the axis is not, and the two produce identical winners.
148
+
149
+ It also shows where the evidence points *off the edge* of what was searched, which
150
+ is the finding a best-point report structurally cannot produce.
151
+
152
+ ``None`` when the parameter did not vary — a constant column has no gradient, and
153
+ reporting a single row would invite reading one value as a trend.
154
+ """
155
+ buckets: Dict[Any, List[float]] = {}
156
+ for row in rows:
157
+ if parameter not in row:
158
+ continue
159
+ score = row.get(objective)
160
+ if score is None or not math.isfinite(float(score)):
161
+ continue
162
+ buckets.setdefault(row[parameter], []).append(float(score))
163
+ if len(buckets) < 2:
164
+ return None
165
+ out = []
166
+ for value in sorted(buckets, key=lambda v: (isinstance(v, str), v)):
167
+ scores = buckets[value]
168
+ out.append(
169
+ {
170
+ "value": value,
171
+ "n": len(scores),
172
+ "positive": int(sum(1 for s in scores if s > 0)),
173
+ "positive_rate": float(sum(1 for s in scores if s > 0) / len(scores)),
174
+ "median": float(np.median(scores)),
175
+ "max": float(np.max(scores)),
176
+ }
177
+ )
178
+ return out
179
+
180
+
181
+ def gradients(
182
+ rows: Sequence[Dict[str, Any]], parameters: Sequence[str], objective: str
183
+ ) -> Dict[str, List[Dict[str, Any]]]:
184
+ """:func:`parameter_gradient` for every parameter that actually varied."""
185
+ out = {}
186
+ for parameter in parameters:
187
+ gradient = parameter_gradient(rows, parameter, objective)
188
+ if gradient is not None:
189
+ out[parameter] = gradient
190
+ return out
@@ -0,0 +1,303 @@
1
+ """Are two recorded results the same result?
2
+
3
+ A campaign accumulates trials that look different — different parameters, different
4
+ kinds, different windows — and some of them are the same trade expressed twice. Two
5
+ candidates correlating at 0.97 are one candidate, and promoting both as independent
6
+ evidence double-counts a single bet. Answering that meant pulling both return series
7
+ out of SQLite by hand and correlating them in a notebook.
8
+
9
+ Everything here is a pure function over stored series and returns structured data. It
10
+ grades nothing. What it is opinionated about is **refusing**, because a correlation is
11
+ a claim about a relationship and there is no honest partial version of one:
12
+
13
+ * **Below the minimum overlap, a pair is refused.** Not computed-and-caveated. A
14
+ correlation over a handful of shared dates is a number with an error bar wider than
15
+ its own range, and printed to two decimals it looks exactly like a measurement.
16
+ * **Across accounting versions, a pair is refused by default.** The two series were
17
+ produced by engines that compute different things; their correlation is a fact about
18
+ the instruments as much as the strategies. Forcing it is allowed and the pair is
19
+ labelled incomparable when you do.
20
+ * **Every correlation carries its interval.** Reported with a Fisher-z confidence
21
+ interval, so a coefficient resting on a short overlap arrives visibly wide rather
22
+ than merely short of decimals.
23
+
24
+ The pairwise matrix alone would hide all of that — a refusal and a correlation of zero
25
+ occupy the same cell — so the per-pair diagnostics travel beside it and the matrix
26
+ holds ``None`` where nothing was computed.
27
+ """
28
+
29
+ import math
30
+ from typing import Any, Dict, List, Optional, Sequence
31
+
32
+ import numpy as np
33
+
34
+ from quantred.analytics import metrics
35
+
36
+ #: Shared dates below which a pair is refused rather than reported. Matches the trial
37
+ #: store's own panel default: the number that makes a joint resampling meaningful is
38
+ #: the same number that makes a pairwise correlation meaningful.
39
+ MIN_OVERLAP = 60
40
+
41
+ #: Confidence level for the interval attached to every correlation.
42
+ CONFIDENCE = 0.95
43
+
44
+ COMPARED = "compared"
45
+ REFUSED = "refused"
46
+
47
+
48
+ def series_summary(entry: Dict[str, Any]) -> Dict[str, Any]:
49
+ """What one trial's stored series is, before anything is compared to it."""
50
+ trial_id = entry.get("trial_id")
51
+ dates, values = entry.get("dates"), entry.get("values")
52
+ summary: Dict[str, Any] = {
53
+ "trial_id": trial_id,
54
+ "accounting": entry.get("accounting"),
55
+ "strategy": entry.get("strategy"),
56
+ "kind": entry.get("kind"),
57
+ "available": False,
58
+ "reason": "",
59
+ "periods": None,
60
+ "start": None,
61
+ "end": None,
62
+ }
63
+ if dates is None or values is None:
64
+ summary["reason"] = (
65
+ "no return series was recorded for this trial — not every trial kind persists "
66
+ "one, and trials predating the companion table have none"
67
+ )
68
+ return summary
69
+ if not dates:
70
+ # Distinct from the line above on purpose. "Nothing was stored" and "a series
71
+ # was stored and it is empty" are different facts about the record.
72
+ summary["reason"] = "the stored return series is empty"
73
+ summary["periods"] = 0
74
+ return summary
75
+
76
+ arr = np.asarray(values, dtype="float64")
77
+ finite = arr[np.isfinite(arr)]
78
+ summary.update(
79
+ {
80
+ "available": True,
81
+ "periods": len(dates),
82
+ "start": str(dates[0]),
83
+ "end": str(dates[-1]),
84
+ "n_finite": int(finite.size),
85
+ "n_unusable": int(arr.size - finite.size),
86
+ "annualized_sharpe": float(metrics.sharpe_ratio(finite)) if finite.size > 1 else None,
87
+ "annualized_volatility": (
88
+ float(metrics.annualized_volatility(finite)) if finite.size > 1 else None
89
+ ),
90
+ "cumulative_return": float(np.prod(1.0 + finite) - 1.0) if finite.size else None,
91
+ }
92
+ )
93
+ return summary
94
+
95
+
96
+ def _correlation_interval(r: float, n: int) -> Optional[Dict[str, float]]:
97
+ """A Fisher-z interval around a correlation coefficient.
98
+
99
+ The point of carrying it is that it does not shrink politely: a coefficient
100
+ resting on a short overlap arrives visibly wide, which is the honest rendering of
101
+ "we do not really know". ``None`` under four observations, where the transform has
102
+ no standard error to work with at all.
103
+ """
104
+ if n < 4 or not -1.0 < r < 1.0:
105
+ # No interval exists at |r| = 1: the transform is undefined there, and it is
106
+ # undefined because the answer is not uncertain. Callers say which of the two
107
+ # reasons applies rather than reporting a missing interval as thin evidence.
108
+ return None
109
+ z = math.atanh(r)
110
+ se = 1.0 / math.sqrt(n - 3)
111
+ half = metrics.norm_ppf(1.0 - (1.0 - CONFIDENCE) / 2.0) * se
112
+ return {"low": float(math.tanh(z - half)), "high": float(math.tanh(z + half))}
113
+
114
+
115
+ def _pair(
116
+ left: Dict[str, Any],
117
+ right: Dict[str, Any],
118
+ *,
119
+ min_overlap: int,
120
+ across_accounting: bool,
121
+ ) -> Dict[str, Any]:
122
+ """One pair: the overlap they share, and the correlation over it or the refusal."""
123
+ out: Dict[str, Any] = {
124
+ "a": left.get("trial_id"),
125
+ "b": right.get("trial_id"),
126
+ "status": REFUSED,
127
+ "reason": "",
128
+ "overlap": 0,
129
+ "start": None,
130
+ "end": None,
131
+ "correlation": None,
132
+ "interval": None,
133
+ }
134
+
135
+ for side in (left, right):
136
+ if not (side.get("dates") and side.get("values")):
137
+ out["reason"] = f"{side.get('trial_id')} has no recorded return series to compare"
138
+ return out
139
+
140
+ acc_left, acc_right = left.get("accounting"), right.get("accounting")
141
+ if acc_left != acc_right and not across_accounting:
142
+ out["reason"] = (
143
+ f"recorded under accounting v{acc_left} and v{acc_right}. The two series were "
144
+ "produced by engines that compute different things, so their correlation is "
145
+ "partly a fact about the instruments. Pass the cross-accounting option to "
146
+ "compare them anyway"
147
+ )
148
+ return out
149
+
150
+ a = dict(zip((str(d) for d in left["dates"]), left["values"]))
151
+ b = dict(zip((str(d) for d in right["dates"]), right["values"]))
152
+ shared = sorted(set(a) & set(b))
153
+ x = np.asarray([a[d] for d in shared], dtype="float64")
154
+ y = np.asarray([b[d] for d in shared], dtype="float64")
155
+ usable = np.isfinite(x) & np.isfinite(y)
156
+ x, y = x[usable], y[usable]
157
+ dates = [d for d, keep in zip(shared, usable) if keep]
158
+
159
+ out["overlap"] = int(x.size)
160
+ out["start"] = dates[0] if dates else None
161
+ out["end"] = dates[-1] if dates else None
162
+
163
+ if x.size < min_overlap:
164
+ out["reason"] = (
165
+ f"{x.size} shared dates, below the {min_overlap} this comparison requires. A "
166
+ "correlation over that little is an error bar, not a measurement"
167
+ )
168
+ return out
169
+ if float(np.std(x)) == 0.0 or float(np.std(y)) == 0.0:
170
+ out["reason"] = "one series does not vary over the shared dates, so nothing can correlate"
171
+ return out
172
+
173
+ r = float(np.corrcoef(x, y)[0, 1])
174
+ out.update(
175
+ {
176
+ "status": COMPARED,
177
+ "correlation": r,
178
+ "interval": _correlation_interval(r, int(x.size)),
179
+ # Why there is no interval, when there is none. "Undefined at |r| = 1" and
180
+ # "too few observations to estimate one" are opposite statements about how
181
+ # much is known, and a bare missing interval reads as the second.
182
+ "interval_note": (
183
+ "no interval: the two series are perfectly correlated over the shared "
184
+ "dates, which is a stronger statement than any interval, not a weaker one"
185
+ if abs(r) >= 1.0
186
+ else "no interval: fewer than four shared observations"
187
+ if x.size < 4
188
+ else ""
189
+ ),
190
+ "comparable": acc_left == acc_right,
191
+ }
192
+ )
193
+ if acc_left != acc_right:
194
+ out["reason"] = (
195
+ f"INCOMPARABLE as evidence: accounting v{acc_left} against v{acc_right}. "
196
+ "Computed because it was asked for"
197
+ )
198
+ return out
199
+
200
+
201
+ def _deduplicate(entries: Sequence[Dict[str, Any]]) -> "tuple[List[Dict[str, Any]], List[str]]":
202
+ """Entries with repeated trial ids collapsed to their first occurrence.
203
+
204
+ A trial correlated against itself is 1.0 by construction and says nothing, and the
205
+ matrix below is keyed by trial id - so a repeat silently overwrote one cell and
206
+ left another unwritten. Reported rather than dropped quietly: asking about three
207
+ ids and being answered about two is worth a line.
208
+ """
209
+ seen: Dict[str, Dict[str, Any]] = {}
210
+ duplicates: List[str] = []
211
+ for entry in entries:
212
+ trial_id = entry.get("trial_id")
213
+ if trial_id in seen:
214
+ duplicates.append(trial_id)
215
+ continue
216
+ seen[trial_id] = entry
217
+ return list(seen.values()), duplicates
218
+
219
+
220
+ #: What a stored return series *is*, by trial kind. A walk-forward's is the out-of-sample
221
+ #: folds stitched together; a backtest's is one pass over its whole window, which is
222
+ #: in-sample. Correlating the two is arithmetic over unlike things, so every comparison
223
+ #: names each series' kind and says when the kinds differ.
224
+ SERIES_BASIS = {
225
+ "walkforward": "stitched out-of-sample folds",
226
+ "research": "stitched out-of-sample folds",
227
+ "backtest": "full-window, in-sample",
228
+ "optimize": "full-window, in-sample",
229
+ }
230
+
231
+
232
+ def series_basis(kind: Optional[str]) -> Optional[str]:
233
+ """What a series of this trial kind measures, or ``None`` for a kind not listed."""
234
+ return SERIES_BASIS.get(kind or "")
235
+
236
+
237
+ def compare_series(
238
+ entries: Sequence[Dict[str, Any]],
239
+ *,
240
+ min_overlap: int = MIN_OVERLAP,
241
+ across_accounting: bool = False,
242
+ ) -> Dict[str, Any]:
243
+ """Every pair among the given trials' recorded return series.
244
+
245
+ ``entries`` are ``{trial_id, dates, values, accounting, strategy, kind}``. The
246
+ result carries the per-series summaries, one diagnostic per pair, and the matrix —
247
+ in that order deliberately, because the matrix is the part that reads as an answer
248
+ and the refusals are the part that says how much of one it is.
249
+ """
250
+ # Deduplicated first, and the duplicates counted. The same id given twice used to
251
+ # collapse in the matrix index below, so a pair that *was* compared got written to
252
+ # one cell and the other was left holding None - which this module defines as a
253
+ # refusal. A paste slip was enough to make the matrix say two results are
254
+ # incomparable when they had just been compared.
255
+ entries, duplicates = _deduplicate(entries)
256
+ summaries = [series_summary(entry) for entry in entries]
257
+ ids = [entry.get("trial_id") for entry in entries]
258
+
259
+ pairs: List[Dict[str, Any]] = []
260
+ for i in range(len(entries)):
261
+ for j in range(i + 1, len(entries)):
262
+ pairs.append(
263
+ _pair(
264
+ entries[i],
265
+ entries[j],
266
+ min_overlap=min_overlap,
267
+ across_accounting=across_accounting,
268
+ )
269
+ )
270
+
271
+ # ``None`` where nothing was computed, never 0.0. In a correlation matrix a zero
272
+ # is a strong claim - these two results move independently - and it is the exact
273
+ # claim a refusal is unable to make.
274
+ index = {tid: n for n, tid in enumerate(ids)}
275
+ matrix: List[List[Optional[float]]] = [
276
+ [1.0 if i == j else None for j in range(len(ids))] for i in range(len(ids))
277
+ ]
278
+ for pair in pairs:
279
+ if pair["status"] != COMPARED:
280
+ continue
281
+ i, j = index[pair["a"]], index[pair["b"]]
282
+ matrix[i][j] = matrix[j][i] = pair["correlation"]
283
+
284
+ compared = [p for p in pairs if p["status"] == COMPARED]
285
+ kind_of = {entry.get("trial_id"): entry.get("kind") for entry in entries}
286
+ for pair in pairs:
287
+ pair["kind_a"], pair["kind_b"] = kind_of.get(pair["a"]), kind_of.get(pair["b"])
288
+ # Unlike bases, not unlike kinds: two walk-forwards and a research round are all
289
+ # out-of-sample series, and a note on every such pair would be noise.
290
+ pair["unlike_basis"] = series_basis(pair["kind_a"]) != series_basis(pair["kind_b"])
291
+ return {
292
+ "kinds_differ": len({series_basis(k) for k in kind_of.values()}) > 1,
293
+ "duplicate_trial_ids": duplicates,
294
+ "min_overlap": min_overlap,
295
+ "across_accounting": across_accounting,
296
+ "series": summaries,
297
+ "pairs": pairs,
298
+ "matrix": {"trial_ids": ids, "values": matrix},
299
+ "n_pairs": len(pairs),
300
+ "n_compared": len(compared),
301
+ "n_refused": len(pairs) - len(compared),
302
+ "highest": (max(compared, key=lambda p: p["correlation"]) if compared else None),
303
+ }