File size: 8,474 Bytes
3339913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2e42a
 
 
 
3339913
2d2e42a
 
 
 
 
3339913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2e42a
 
3339913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2e42a
3339913
 
 
 
2d2e42a
 
3339913
 
 
 
 
 
 
2d2e42a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3339913
 
 
 
2d2e42a
3339913
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2e42a
 
3339913
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
"""Turn a pile of statistics into one number and one sentence.

The Reality Score is deliberately harsh. Most published backtests would score
below 40, and that is the point: the score exists to be screenshotted.
"""

from __future__ import annotations

from typing import Dict, List, Optional

import numpy as np

__all__ = ["reality_score", "GRADES"]

GRADES = [
    (85, "A", "Survives everything we threw at it"),
    (70, "B", "Probably a real edge, with caveats"),
    (55, "C", "Ambiguous β€” could go either way"),
    (40, "D", "Mostly luck"),
    (0, "F", "Indistinguishable from randomness"),
]

WEIGHTS = {
    "significance": 0.30,
    "selection": 0.25,
    "walk_forward": 0.20,
    "overfitting": 0.15,
    "robustness": 0.10,
}


def _ramp(value: float, good: float, bad: float) -> float:
    """Linear 0-100 score where ``good`` maps to 100 and ``bad`` maps to 0."""
    if not np.isfinite(value):
        return 50.0
    if good == bad:
        return 50.0
    scaled = (value - bad) / (good - bad)
    return float(np.clip(scaled, 0.0, 1.0) * 100.0)


def reality_score(
    metrics: Dict[str, float],
    benchmark_metrics: Dict[str, float],
    p_value: Optional[float] = None,
    dsr: Optional[float] = None,
    pbo: Optional[float] = None,
    wf_efficiency: Optional[float] = None,
    wf_win_rate: Optional[float] = None,
    cost_stress_ratio: Optional[float] = None,
    benchmark_correlation: Optional[float] = None,
    attribution: Optional[Dict[str, object]] = None,
    survivorship: Optional[object] = None,
    benchmark_name: str = "Buy & hold",
    permutation_label: str = "shuffled, structure-free markets",
) -> Dict[str, object]:
    """Combine the validation panel into a 0-100 score, a grade and warnings.

    ``attribution`` and ``survivorship`` are the portfolio-only inputs; both are
    optional so the single-asset path is unaffected.
    """
    components: Dict[str, float] = {}

    components["significance"] = _ramp(p_value, good=0.01, bad=0.50) if p_value is not None else 50.0
    components["selection"] = float(np.clip(dsr, 0.0, 1.0) * 100.0) if dsr is not None else 50.0

    if wf_efficiency is not None:
        wf = _ramp(wf_efficiency, good=0.8, bad=-0.2)
        if wf_win_rate is not None:
            wf = 0.7 * wf + 0.3 * float(np.clip(wf_win_rate, 0.0, 1.0) * 100.0)
        components["walk_forward"] = wf
    else:
        components["walk_forward"] = 50.0

    components["overfitting"] = _ramp(pbo, good=0.05, bad=0.50) if pbo is not None and np.isfinite(pbo) else 50.0
    components["robustness"] = (
        _ramp(cost_stress_ratio, good=0.8, bad=0.0) if cost_stress_ratio is not None else 50.0
    )

    score = float(sum(components[k] * w for k, w in WEIGHTS.items()))

    flags: List[str] = []
    n_trades = int(metrics.get("n_trades", 0))
    sharpe = float(metrics.get("sharpe", 0.0))
    total_return = float(metrics.get("total_return", 0.0))
    max_dd = float(metrics.get("max_drawdown", 0.0))
    bench_sharpe = float(benchmark_metrics.get("sharpe", 0.0))

    # The score asks "is the measured edge real?" β€” if the strategy lost money,
    # there is no edge to validate, whatever the statistics say about it.
    if total_return <= 0:
        flags.append(
            f"The strategy lost money over the test period ({total_return:.1%}). "
            "There is no edge here to validate."
        )
        score = min(score, 50.0)
    if total_return <= 0 < sharpe:
        flags.append(
            "Positive Sharpe with a negative total return: the average bar was profitable but "
            "the compounding was not. Volatility drag ate the arithmetic edge."
        )
    if max_dd < -0.5:
        flags.append(
            f"Peak-to-trough drawdown of {max_dd:.0%} β€” an account running this would have been "
            "closed long before the recovery arrived."
        )
        score = min(score, 60.0)

    if n_trades < 20:
        flags.append(
            f"Only {n_trades} position changes β€” with this few decisions, "
            "the result is a handful of coin flips, not a track record."
        )
        score = min(score, 55.0)
    if p_value is not None and p_value > 0.10:
        flags.append(
            f"Permutation p-value is {p_value:.2f}: roughly {p_value * 100:.0f}% of "
            f"{permutation_label} did this well or better."
        )
    if dsr is not None and dsr < 0.5:
        flags.append(
            f"Deflated Sharpe is {dsr:.2f} β€” once you account for how many variants were tried, "
            "the edge does not clear the selection-bias bar."
        )
    if pbo is not None and np.isfinite(pbo) and pbo > 0.3:
        flags.append(
            f"Probability of backtest overfitting is {pbo:.0%}: the in-sample winner "
            "usually lands in the bottom half out of sample."
        )
    if wf_efficiency is not None and wf_efficiency < 0.3:
        flags.append(
            f"Walk-forward efficiency is {wf_efficiency:.0%} β€” most of the in-sample Sharpe "
            "does not survive re-tuning and trading forward."
        )
    if cost_stress_ratio is not None and cost_stress_ratio < 0.5:
        flags.append(
            "Tripling trading costs removes more than half the Sharpe. The edge is "
            "smaller than the friction it has to pay."
        )
    if benchmark_correlation is not None and benchmark_correlation > 0.95:
        flags.append(
            f"Returns are {benchmark_correlation:.0%} correlated with {benchmark_name.lower()} β€” "
            "this is mostly a repackaged long position."
        )
    if sharpe < bench_sharpe:
        flags.append(
            f"{benchmark_name} beat it on risk-adjusted return "
            f"({bench_sharpe:.2f} vs {sharpe:.2f} Sharpe)."
        )
    if float(metrics.get("turnover_ann", 0.0)) > 100:
        flags.append(
            f"Annual turnover of {metrics.get('turnover_ann', 0):.0f}x is far beyond what "
            "retail execution can absorb without moving the modelled fills."
        )

    # Portfolio-only checks. Style exposure you could buy in an ETF is not
    # alpha, and a universe with no failures in it is not a universe.
    if attribution and attribution.get("available"):
        if not attribution.get("alpha_significant"):
            flags.append(
                f"Style regression leaves no significant alpha (t = "
                f"{attribution.get('alpha_t_stat', 0):.1f}); the factors explain "
                f"{attribution.get('r_squared', 0):.0%} of returns"
                + (
                    f", mostly {attribution['dominant_factor']} exposure."
                    if attribution.get("dominant_factor")
                    else "."
                )
            )
            score = min(score, 65.0)
        if float(attribution.get("r_squared", 0.0)) > 0.9:
            flags.append(
                "Over 90% of the return variation is explained by simple style factors β€” "
                "this book is a repackaged index."
            )
    if survivorship is not None and getattr(survivorship, "biased", False):
        flags.append(getattr(survivorship, "note", "Universe appears survivorship-biased."))
        score = min(score, 60.0)

    score = float(np.clip(score, 0.0, 100.0))
    grade, headline = next((g, h) for threshold, g, h in GRADES if score >= threshold)

    if score >= 70:
        verdict = (
            f"Grade {grade}. {headline}. The edge is still there after the permutation test, "
            "after charging for every variant tried, and after walking it forward."
        )
    elif score >= 55:
        verdict = (
            f"Grade {grade}. {headline}. Parts of the panel hold up and parts do not β€” "
            "this is the zone where more data, not more tuning, is what settles it."
        )
    elif score >= 40:
        verdict = (
            f"Grade {grade}. {headline}. The backtest looks better than the evidence supports; "
            "the gap between the two is selection bias."
        )
    else:
        verdict = (
            f"Grade {grade}. {headline}. The null β€” {permutation_label} β€” produces results "
            "like this often enough that there is nothing here to trade."
        )

    return {
        "score": round(score, 1),
        "grade": grade,
        "headline": headline,
        "verdict": verdict,
        "components": {k: round(v, 1) for k, v in components.items()},
        "flags": flags,
    }