quantred 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
}
|