beatnothing 0.2.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.
- beatnothing/__init__.py +18 -0
- beatnothing/canary.py +57 -0
- beatnothing/cli.py +137 -0
- beatnothing/data/SOURCE.md +14 -0
- beatnothing/data/sp500_membership_by_date.csv.gz +0 -0
- beatnothing/data/sp500_ticker_start_end.csv.gz +0 -0
- beatnothing/data/still_listed_leavers.json +49 -0
- beatnothing/data/ticker_aliases.json +125 -0
- beatnothing/engine.py +132 -0
- beatnothing/features.py +140 -0
- beatnothing/leaderboard.py +300 -0
- beatnothing/models.py +102 -0
- beatnothing/score.py +117 -0
- beatnothing/stats.py +401 -0
- beatnothing/universe.py +92 -0
- beatnothing/validate.py +220 -0
- beatnothing-0.2.0.dist-info/METADATA +496 -0
- beatnothing-0.2.0.dist-info/RECORD +22 -0
- beatnothing-0.2.0.dist-info/WHEEL +5 -0
- beatnothing-0.2.0.dist-info/entry_points.txt +2 -0
- beatnothing-0.2.0.dist-info/licenses/LICENSE +21 -0
- beatnothing-0.2.0.dist-info/top_level.txt +1 -0
beatnothing/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
beatnothing: can your model beat doing nothing, after costs, without knowing the future?
|
|
3
|
+
|
|
4
|
+
A small, reproducible benchmark harness for daily equity signals. Every contestant is
|
|
5
|
+
scored through one identical engine against one bar: an equal weight position in the
|
|
6
|
+
same point in time universe, held with no skill at all. The score is the Net Edge,
|
|
7
|
+
the net Sharpe of the strategy minus the net Sharpe of doing nothing, with a paired
|
|
8
|
+
bootstrap confidence interval so that noise cannot masquerade as alpha.
|
|
9
|
+
"""
|
|
10
|
+
from .canary import hindsight_universe, off_by_one, peek, truncation_test
|
|
11
|
+
from .engine import Backtest, COST_BPS, TRADING_DAYS
|
|
12
|
+
from .score import bootstrap_sharpe_diff, cost_grid, net_edge, probabilistic_sharpe, sharpe
|
|
13
|
+
from .universe import Membership, coverage_report, start_end_table
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
__all__ = ["Backtest", "COST_BPS", "TRADING_DAYS", "net_edge", "sharpe", "bootstrap_sharpe_diff",
|
|
17
|
+
"probabilistic_sharpe", "cost_grid", "Membership", "coverage_report", "start_end_table",
|
|
18
|
+
"peek", "off_by_one", "hindsight_universe", "truncation_test", "__version__"]
|
beatnothing/canary.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Leak canaries: contestants that cheat on purpose.
|
|
3
|
+
|
|
4
|
+
Run them through your evaluation pipeline. If they do not score absurdly well,
|
|
5
|
+
your pipeline is not measuring what you think it is. Verify the verifier.
|
|
6
|
+
|
|
7
|
+
Also here: the truncation test that proves features are trailing only. Delete
|
|
8
|
+
everything after a cutoff, recompute, and the surviving rows must be identical.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def peek(actual: pd.DataFrame) -> pd.DataFrame:
|
|
17
|
+
"""Uses the return of t+1 as the prediction on t. The loudest possible leak."""
|
|
18
|
+
return actual.copy()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def off_by_one(feature: pd.DataFrame) -> pd.DataFrame:
|
|
22
|
+
"""A trailing feature shifted the wrong way (the classic misaligned index):
|
|
23
|
+
the value observed on t+1 is used on t."""
|
|
24
|
+
return feature.shift(-1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hindsight_universe(actual: pd.DataFrame, top_n: int = 10) -> pd.DataFrame:
|
|
28
|
+
"""Always long the `top_n` names with the highest realised return over the whole
|
|
29
|
+
window. Survivorship and selection bias in one line."""
|
|
30
|
+
total = (1 + actual.fillna(0)).prod()
|
|
31
|
+
winners = total.sort_values(ascending=False).index[:top_n]
|
|
32
|
+
pred = pd.DataFrame(0.0, index=actual.index, columns=actual.columns)
|
|
33
|
+
pred[winners] = 1.0
|
|
34
|
+
return pred
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def truncation_test(build_features, prices: pd.DataFrame, cutoff, keys=("Date", "Ticker"),
|
|
38
|
+
atol: float = 1e-9) -> dict:
|
|
39
|
+
"""
|
|
40
|
+
Prove features are trailing only. `build_features(prices)` must return a long
|
|
41
|
+
frame keyed by `keys`. Rows on or before `cutoff` must be identical whether or
|
|
42
|
+
not data after `cutoff` exists. The target column is excluded from the verdict
|
|
43
|
+
because it is the one deliberate forward look (it needs t+1 to exist).
|
|
44
|
+
"""
|
|
45
|
+
full = build_features(prices)
|
|
46
|
+
trunc = build_features(prices[prices["Date"] <= pd.Timestamp(cutoff)])
|
|
47
|
+
full = full[full["Date"] <= pd.Timestamp(cutoff)]
|
|
48
|
+
merged = full.merge(trunc, on=list(keys), suffixes=("_full", "_trunc"))
|
|
49
|
+
cols = [c for c in full.columns if c not in keys]
|
|
50
|
+
worst = {}
|
|
51
|
+
for c in cols:
|
|
52
|
+
a = merged[f"{c}_full"].to_numpy(float)
|
|
53
|
+
b = merged[f"{c}_trunc"].to_numpy(float)
|
|
54
|
+
mask = np.isfinite(a) & np.isfinite(b)
|
|
55
|
+
worst[c] = float(np.max(np.abs(a[mask] - b[mask]))) if mask.any() else 0.0
|
|
56
|
+
return {"rows_compared": int(len(merged)), "max_abs_diff": worst,
|
|
57
|
+
"passed": bool(all(v <= atol for c, v in worst.items() if c != "target"))}
|
beatnothing/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line entry point.
|
|
3
|
+
|
|
4
|
+
beatnothing score my_signal.parquet --actual data/actual_returns.parquet
|
|
5
|
+
beatnothing score my_weights.parquet --actual ... --kind weights --start 2022-01-01
|
|
6
|
+
beatnothing leaderboard [--track survivor48|pit] [--root PATH] [--n-boot 2000]
|
|
7
|
+
beatnothing pbo v1.parquet v2.parquet v3.parquet --actual data/pit/actual_returns_pit.parquet
|
|
8
|
+
beatnothing validate submissions_pit/my_model
|
|
9
|
+
beatnothing members 2008-09-15
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _score(args) -> int:
|
|
20
|
+
from .leaderboard import score_signal
|
|
21
|
+
res = score_signal(args.signal, args.actual, kind=args.kind, start=args.start, end=args.end,
|
|
22
|
+
n_boot=args.n_boot, cost_bps=args.cost_bps, bars_path=args.bars)
|
|
23
|
+
keys = ["window", "days", "net_edge", "ci_low", "ci_high", "clears_bar", "net_sharpe", "bar_sharpe",
|
|
24
|
+
"edge_vs_investable", "ci_low_vs_investable", "ci_high_vs_investable", "clears_investable",
|
|
25
|
+
"investable_sharpe", "gross_sharpe", "net_sharpe_20bps", "sharpe_se", "psr_vs_zero",
|
|
26
|
+
"max_drawdown", "annual_turnover", "avg_exposure", "dollar_pnl"]
|
|
27
|
+
print(json.dumps({k: res[k] for k in keys if k in res}, indent=2, default=str))
|
|
28
|
+
verdict = "clears the bar" if res["clears_bar"] else "does not clear the bar"
|
|
29
|
+
print(f"\nNet Edge {res['net_edge']:+.2f} [{res['ci_low']:+.2f}, {res['ci_high']:+.2f}]: {verdict}.", file=sys.stderr)
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _leaderboard(args) -> int:
|
|
34
|
+
from .leaderboard import main
|
|
35
|
+
board = main(args.actual, n_boot=args.n_boot, root=args.root, track=args.track)
|
|
36
|
+
print(f"scored {len(board['entries'])} submissions on the {args.track} track; leaderboard written")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _pbo(args) -> int:
|
|
41
|
+
import numpy as np
|
|
42
|
+
import pandas as pd
|
|
43
|
+
from .stats import deflated_sharpe, pbo_cscv
|
|
44
|
+
from .engine import Backtest, TRADING_DAYS
|
|
45
|
+
from .leaderboard import load_actual, load_signal
|
|
46
|
+
|
|
47
|
+
actual = load_actual(args.actual)
|
|
48
|
+
curves, names = [], []
|
|
49
|
+
for path in args.signals:
|
|
50
|
+
wide = load_signal(path)
|
|
51
|
+
bt = Backtest(actual, predictions=wide, rule=args.rule, quantile=args.quantile)
|
|
52
|
+
curves.append(bt.daily_returns.reindex(actual.index).fillna(0.0).values)
|
|
53
|
+
p = Path(path)
|
|
54
|
+
names.append(p.parent.name if p.stem == "signal" else p.stem) # every submission file is signal.parquet
|
|
55
|
+
R = np.column_stack(curves)
|
|
56
|
+
res = pbo_cscv(R, n_splits=args.splits)
|
|
57
|
+
srs = R.mean(0) / R.std(0) * np.sqrt(TRADING_DAYS)
|
|
58
|
+
best = int(np.argmax(srs))
|
|
59
|
+
deflated = deflated_sharpe(R[:, best], n_trials=R.shape[1], sr_variance=float(np.var(srs, ddof=1)))
|
|
60
|
+
print(json.dumps({"variants": names, "best": names[best], "best_sharpe": round(float(srs[best]), 3),
|
|
61
|
+
"probability_of_backtest_overfitting": round(res["pbo"], 3),
|
|
62
|
+
"median_out_of_sample_rank": res["median_rank"], "combinations": res["n_combinations"],
|
|
63
|
+
"deflated_sharpe": round(deflated["deflated_sharpe"], 3),
|
|
64
|
+
"sharpe_the_search_alone_would_give": round(deflated["expected_max_sharpe"], 3)}, indent=2))
|
|
65
|
+
if res["pbo"] > 0.4:
|
|
66
|
+
print("\nAt this overfitting probability the best variant is roughly what searching alone would "
|
|
67
|
+
"produce. Submit the one you chose before looking, or none.", file=sys.stderr)
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _validate(args) -> int:
|
|
72
|
+
from .leaderboard import TRACKS, load_actual
|
|
73
|
+
from .validate import validate_submission
|
|
74
|
+
folder = Path(args.folder)
|
|
75
|
+
track = next((t for t, c in TRACKS.items() if c["submissions"] in folder.parts), "survivor48")
|
|
76
|
+
actual_path = Path(args.actual) if args.actual else Path(TRACKS[track]["actual"])
|
|
77
|
+
actual = load_actual(actual_path) if actual_path.exists() else None
|
|
78
|
+
rep = validate_submission(folder, actual)
|
|
79
|
+
print(rep.render())
|
|
80
|
+
return 0 if rep.ok else 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _members(args) -> int:
|
|
84
|
+
from .universe import Membership
|
|
85
|
+
members = sorted(Membership().on(args.date))
|
|
86
|
+
print(f"{len(members)} members on {args.date}")
|
|
87
|
+
print(" ".join(members))
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv=None) -> int:
|
|
92
|
+
ap = argparse.ArgumentParser(prog="beatnothing",
|
|
93
|
+
description="Can your model beat doing nothing, after costs, without knowing the future?")
|
|
94
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
95
|
+
|
|
96
|
+
s = sub.add_parser("score", help="score one signal file against realised returns")
|
|
97
|
+
s.add_argument("signal", help="parquet with columns Date, Ticker, value")
|
|
98
|
+
s.add_argument("--actual", required=True, help="parquet with columns Date, Ticker, target (next day return)")
|
|
99
|
+
s.add_argument("--kind", choices=["predictions", "weights"], default="predictions")
|
|
100
|
+
s.add_argument("--start", default=None)
|
|
101
|
+
s.add_argument("--end", default=None)
|
|
102
|
+
s.add_argument("--n-boot", type=int, default=2000)
|
|
103
|
+
s.add_argument("--cost-bps", type=float, default=10.0)
|
|
104
|
+
s.add_argument("--bars", default=None, help="parquet with Date and RSP columns to also score against the investable bar")
|
|
105
|
+
s.set_defaults(func=_score)
|
|
106
|
+
|
|
107
|
+
lb = sub.add_parser("leaderboard", help="score every submission under a benchmark root")
|
|
108
|
+
lb.add_argument("--root", default=None)
|
|
109
|
+
lb.add_argument("--actual", default=None)
|
|
110
|
+
lb.add_argument("--n-boot", type=int, default=2000)
|
|
111
|
+
lb.add_argument("--track", choices=["survivor48", "pit"], default="survivor48")
|
|
112
|
+
lb.set_defaults(func=_leaderboard)
|
|
113
|
+
|
|
114
|
+
p = sub.add_parser("pbo", help="how much of your best variant was the search itself "
|
|
115
|
+
"(meaningful from about eight variants upward)")
|
|
116
|
+
p.add_argument("signals", nargs="+", help="one signal parquet per variant you tried")
|
|
117
|
+
p.add_argument("--actual", required=True)
|
|
118
|
+
p.add_argument("--rule", choices=["long_flat", "long_top", "long_short"], default="long_flat")
|
|
119
|
+
p.add_argument("--quantile", type=float, default=0.1)
|
|
120
|
+
p.add_argument("--splits", type=int, default=10)
|
|
121
|
+
p.set_defaults(func=_pbo)
|
|
122
|
+
|
|
123
|
+
v = sub.add_parser("validate", help="check a submission folder against the rules")
|
|
124
|
+
v.add_argument("folder")
|
|
125
|
+
v.add_argument("--actual", default=None)
|
|
126
|
+
v.set_defaults(func=_validate)
|
|
127
|
+
|
|
128
|
+
m = sub.add_parser("members", help="who was in the S&P 500 on a date")
|
|
129
|
+
m.add_argument("date")
|
|
130
|
+
m.set_defaults(func=_members)
|
|
131
|
+
|
|
132
|
+
args = ap.parse_args(argv)
|
|
133
|
+
return args.func(args)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Point in time S&P 500 membership
|
|
2
|
+
|
|
3
|
+
Both files come from the MIT licensed repository https://github.com/fja05680/sp500
|
|
4
|
+
(snapshot taken 17 September 2026).
|
|
5
|
+
|
|
6
|
+
* `sp500_membership_by_date.csv` lists the full constituent set on every date the index
|
|
7
|
+
changed, from 1996 to 18 August 2026.
|
|
8
|
+
* `sp500_ticker_start_end.csv` lists each ticker's entry and exit dates.
|
|
9
|
+
* `still_listed_leavers.json` records which of the members on 31 December 2021 that later
|
|
10
|
+
left the index still returned prices from Yahoo Finance on 17 September 2026. The
|
|
11
|
+
complement (47 names) is the residual survivorship gap reported on the leaderboard page.
|
|
12
|
+
|
|
13
|
+
The maintainer cross checks Wikipedia's change log every couple of months; early years
|
|
14
|
+
are less reliable than recent ones, and the index rarely holds exactly 500 names.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[
|
|
2
|
+
"AAL",
|
|
3
|
+
"AAP",
|
|
4
|
+
"ALK",
|
|
5
|
+
"BBWI",
|
|
6
|
+
"BIO",
|
|
7
|
+
"BWA",
|
|
8
|
+
"CAG",
|
|
9
|
+
"CE",
|
|
10
|
+
"CPB",
|
|
11
|
+
"CZR",
|
|
12
|
+
"DXC",
|
|
13
|
+
"EMN",
|
|
14
|
+
"ENPH",
|
|
15
|
+
"EPAM",
|
|
16
|
+
"ETSY",
|
|
17
|
+
"FB",
|
|
18
|
+
"FISV",
|
|
19
|
+
"FMC",
|
|
20
|
+
"ILMN",
|
|
21
|
+
"INFO",
|
|
22
|
+
"IPGP",
|
|
23
|
+
"KMX",
|
|
24
|
+
"LKQ",
|
|
25
|
+
"LNC",
|
|
26
|
+
"LUMN",
|
|
27
|
+
"LW",
|
|
28
|
+
"MHK",
|
|
29
|
+
"MKTX",
|
|
30
|
+
"MTCH",
|
|
31
|
+
"NWL",
|
|
32
|
+
"OGN",
|
|
33
|
+
"PAYC",
|
|
34
|
+
"PENN",
|
|
35
|
+
"POOL",
|
|
36
|
+
"PVH",
|
|
37
|
+
"QRVO",
|
|
38
|
+
"RHI",
|
|
39
|
+
"SBNY",
|
|
40
|
+
"SEDG",
|
|
41
|
+
"TFX",
|
|
42
|
+
"UA",
|
|
43
|
+
"UAA",
|
|
44
|
+
"VFC",
|
|
45
|
+
"VNO",
|
|
46
|
+
"WHR",
|
|
47
|
+
"XRAY",
|
|
48
|
+
"ZION"
|
|
49
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_about": "Index members that changed ticker without ceasing to trade. The membership tables use the old symbol; a free price source holds the full history under the new one. Verified against Yahoo Finance on 2026 09 17 (history start date shown). Names that were acquired or went bankrupt are not here; those need an archive that keeps delisted histories.",
|
|
3
|
+
"ABC": {
|
|
4
|
+
"alias": "COR",
|
|
5
|
+
"history_from": "2005-01-03",
|
|
6
|
+
"note": "AmerisourceBergen renamed Cencora, 2023"
|
|
7
|
+
},
|
|
8
|
+
"ANTM": {
|
|
9
|
+
"alias": "ELV",
|
|
10
|
+
"history_from": "2005-01-03",
|
|
11
|
+
"note": "Anthem renamed Elevance Health, 2022"
|
|
12
|
+
},
|
|
13
|
+
"BLL": {
|
|
14
|
+
"alias": "BALL",
|
|
15
|
+
"history_from": "2005-01-03",
|
|
16
|
+
"note": "Ball Corporation ticker change, 2022"
|
|
17
|
+
},
|
|
18
|
+
"RE": {
|
|
19
|
+
"alias": "EG",
|
|
20
|
+
"history_from": "2005-01-03",
|
|
21
|
+
"note": "Everest Re renamed Everest Group, 2023"
|
|
22
|
+
},
|
|
23
|
+
"PEAK": {
|
|
24
|
+
"alias": "DOC",
|
|
25
|
+
"history_from": "2005-01-03",
|
|
26
|
+
"note": "Healthpeak ticker change after the Physicians Realty merger, 2024"
|
|
27
|
+
},
|
|
28
|
+
"PKI": {
|
|
29
|
+
"alias": "RVTY",
|
|
30
|
+
"history_from": "2005-01-03",
|
|
31
|
+
"note": "PerkinElmer renamed Revvity, 2023"
|
|
32
|
+
},
|
|
33
|
+
"NLOK": {
|
|
34
|
+
"alias": "GEN",
|
|
35
|
+
"history_from": "2005-01-03",
|
|
36
|
+
"note": "NortonLifeLock renamed Gen Digital, 2022"
|
|
37
|
+
},
|
|
38
|
+
"GPS": {
|
|
39
|
+
"alias": "GAP",
|
|
40
|
+
"history_from": "2005-01-03",
|
|
41
|
+
"note": "Gap ticker change, 2024"
|
|
42
|
+
},
|
|
43
|
+
"BK": {
|
|
44
|
+
"alias": "BNY",
|
|
45
|
+
"history_from": "2005-01-03",
|
|
46
|
+
"note": "Bank of New York Mellon ticker change, 2025"
|
|
47
|
+
},
|
|
48
|
+
"MMC": {
|
|
49
|
+
"alias": "MRSH",
|
|
50
|
+
"history_from": "2005-01-03",
|
|
51
|
+
"note": "Marsh McLennan ticker change"
|
|
52
|
+
},
|
|
53
|
+
"FLT": {
|
|
54
|
+
"alias": "CPAY",
|
|
55
|
+
"history_from": "2010-12-15",
|
|
56
|
+
"note": "FleetCor renamed Corpay, 2024"
|
|
57
|
+
},
|
|
58
|
+
"FBHS": {
|
|
59
|
+
"alias": "FBIN",
|
|
60
|
+
"history_from": "2011-09-16",
|
|
61
|
+
"note": "Fortune Brands Home and Security renamed Fortune Brands Innovations, 2022"
|
|
62
|
+
},
|
|
63
|
+
"WRK": {
|
|
64
|
+
"alias": "SW",
|
|
65
|
+
"history_from": "2008-06-17",
|
|
66
|
+
"note": "WestRock merged into Smurfit Westrock, 2024; Yahoo continues the series"
|
|
67
|
+
},
|
|
68
|
+
"DISCA": {
|
|
69
|
+
"alias": "WBD",
|
|
70
|
+
"history_from": "2005-07-08",
|
|
71
|
+
"note": "Discovery merged into Warner Bros. Discovery, 2022; Yahoo continues the series"
|
|
72
|
+
},
|
|
73
|
+
"DISCK": {
|
|
74
|
+
"alias": "WBD",
|
|
75
|
+
"history_from": "2005-07-08",
|
|
76
|
+
"note": "Discovery class C, same continuation as DISCA"
|
|
77
|
+
},
|
|
78
|
+
"FRC": {
|
|
79
|
+
"alias": "FRCB",
|
|
80
|
+
"history_from": "2010-12-09",
|
|
81
|
+
"note": "First Republic Bank after its 2023 failure; served under FRCB by Yahoo and Tiingo"
|
|
82
|
+
},
|
|
83
|
+
"SIVB": {
|
|
84
|
+
"alias": "SIVBQ",
|
|
85
|
+
"history_from": "2005-01-03",
|
|
86
|
+
"note": "SVB Financial after its 2023 failure; Tiingo serves the continued series under SIVB and SIVBQ, Yahoo has neither"
|
|
87
|
+
},
|
|
88
|
+
"HFC": {
|
|
89
|
+
"alias": "DINO",
|
|
90
|
+
"history_from": "2005-01-03",
|
|
91
|
+
"note": "HollyFrontier became HF Sinclair, 2022; Yahoo continues the series"
|
|
92
|
+
},
|
|
93
|
+
"WLTW": {
|
|
94
|
+
"alias": "WTW",
|
|
95
|
+
"history_from": "2005-01-03",
|
|
96
|
+
"note": "Willis Towers Watson ticker change, 2022"
|
|
97
|
+
},
|
|
98
|
+
"VIAC": {
|
|
99
|
+
"alias": "PARA",
|
|
100
|
+
"history_from": "2021-02-12",
|
|
101
|
+
"note": "ViacomCBS renamed Paramount Global, 2022; Yahoo holds PARA from February 2021, which covers every day VIAC was a member after 2021"
|
|
102
|
+
},
|
|
103
|
+
"ADS": {
|
|
104
|
+
"alias": "BFH",
|
|
105
|
+
"history_from": "2001-06-15",
|
|
106
|
+
"note": "Alliance Data Systems renamed Bread Financial, 2022"
|
|
107
|
+
},
|
|
108
|
+
"CTL": {
|
|
109
|
+
"alias": "LUMN",
|
|
110
|
+
"history_from": "2000-01-03",
|
|
111
|
+
"note": "CenturyLink renamed Lumen Technologies, 2020"
|
|
112
|
+
},
|
|
113
|
+
"TMK": {
|
|
114
|
+
"alias": "GL",
|
|
115
|
+
"history_from": "2000-01-03",
|
|
116
|
+
"note": "Torchmark renamed Globe Life, 2019"
|
|
117
|
+
},
|
|
118
|
+
"_rejected": {
|
|
119
|
+
"NYX": "acquired by Intercontinental Exchange in 2013; ICE is a different company, not a rename",
|
|
120
|
+
"CCE": "Coca-Cola Enterprises was split in 2010 and merged into Coca-Cola European Partners in 2016",
|
|
121
|
+
"ESV": "Ensco became Valaris, whose equity was wiped out in 2021; the VAL series is a new company",
|
|
122
|
+
"CBS": "CBS merged into ViacomCBS then Paramount; the PARA series begins in 2021",
|
|
123
|
+
"_note": "these pass a naive continuity test and are still wrong. A successor is only usable when it is the same legal entity renamed."
|
|
124
|
+
}
|
|
125
|
+
}
|
beatnothing/engine.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One engine for every contestant.
|
|
3
|
+
|
|
4
|
+
A contestant hands in either predictions (dates x tickers, any real number) or
|
|
5
|
+
weights (dates x tickers). Predictions become positions by one of three fixed rules:
|
|
6
|
+
|
|
7
|
+
long_flat equal weight long every name with a positive prediction, cash otherwise
|
|
8
|
+
(the default, and the rule of season one)
|
|
9
|
+
long_top equal weight long the top `quantile` of names by prediction each day
|
|
10
|
+
long_short long the top `quantile` and short the bottom `quantile`, equal weight
|
|
11
|
+
within each side, half the capital on each side, so the book is dollar
|
|
12
|
+
neutral and its gross exposure is one
|
|
13
|
+
|
|
14
|
+
Weights are used as given. Long only unless `allow_short`, gross exposure never above
|
|
15
|
+
one, no leverage. Every unit of turnover pays `cost_bps`, day one included, and every
|
|
16
|
+
dollar held short pays `borrow_bps_annual` a year. Net numbers only.
|
|
17
|
+
|
|
18
|
+
Deliberately simple: trades at the close, no market impact beyond the cost parameter,
|
|
19
|
+
no short rebate. Simple enough that nobody can hide anything in it.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
COST_BPS = 10.0
|
|
27
|
+
BORROW_BPS_ANNUAL = 50.0
|
|
28
|
+
TRADING_DAYS = 252
|
|
29
|
+
INITIAL_CAPITAL = 1_000_000.0
|
|
30
|
+
RULES = ("long_flat", "long_top", "long_short")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _equal_weight(mask: pd.DataFrame) -> pd.DataFrame:
|
|
34
|
+
n = mask.sum(axis=1)
|
|
35
|
+
return mask.div(n.replace(0, np.nan), axis=0).fillna(0.0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def weights_from_predictions(predictions: pd.DataFrame, actual: pd.DataFrame, rule: str = "long_flat",
|
|
39
|
+
quantile: float = 0.1) -> pd.DataFrame:
|
|
40
|
+
"""Turn predictions into weights under one of the fixed rules, on names with a realised return."""
|
|
41
|
+
if rule not in RULES:
|
|
42
|
+
raise ValueError(f"rule must be one of {RULES}")
|
|
43
|
+
valid = actual.notna()
|
|
44
|
+
if rule == "long_flat":
|
|
45
|
+
return _equal_weight((predictions > 0) & valid)
|
|
46
|
+
# Ties are broken deterministically so that a decile rule always holds a decile. A
|
|
47
|
+
# signal with few distinct values, which is what an early stopped tree model produces,
|
|
48
|
+
# otherwise gives most names the same average rank, no name clears the threshold and
|
|
49
|
+
# the book sits empty: the rule would silently stop trading rather than say it cannot
|
|
50
|
+
# rank. Breaking ties by column order is arbitrary, and that is the honest reading of
|
|
51
|
+
# a signal that declines to distinguish those names; `beatnothing.validate` warns when
|
|
52
|
+
# a submission has too little resolution for the rule it asked for.
|
|
53
|
+
ranks = predictions.where(valid).rank(axis=1, pct=True, method="first")
|
|
54
|
+
longs = (ranks > 1 - quantile) & valid
|
|
55
|
+
if rule == "long_top":
|
|
56
|
+
return _equal_weight(longs)
|
|
57
|
+
shorts = (ranks <= quantile) & valid
|
|
58
|
+
return 0.5 * _equal_weight(longs) - 0.5 * _equal_weight(shorts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Backtest:
|
|
62
|
+
"""
|
|
63
|
+
Args:
|
|
64
|
+
actual: realised next day simple returns (dates x tickers), aligned so
|
|
65
|
+
that row t is the return earned from the close of t to t+1.
|
|
66
|
+
predictions: contestant predictions (dates x tickers), or None if `weights` given.
|
|
67
|
+
weights: contestant weights (dates x tickers); gross exposure at most 1.
|
|
68
|
+
rule: how predictions become weights (see module docstring).
|
|
69
|
+
quantile: fraction of names on each side for long_top and long_short.
|
|
70
|
+
cost_bps: cost per unit of turnover, in basis points.
|
|
71
|
+
borrow_bps_annual: annual cost of every dollar held short.
|
|
72
|
+
allow_short: accept negative weights (set automatically for long_short).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, actual: pd.DataFrame, predictions: pd.DataFrame | None = None,
|
|
76
|
+
weights: pd.DataFrame | None = None, rule: str = "long_flat", quantile: float = 0.1,
|
|
77
|
+
cost_bps: float = COST_BPS, borrow_bps_annual: float = BORROW_BPS_ANNUAL,
|
|
78
|
+
allow_short: bool = False, capital: float = INITIAL_CAPITAL):
|
|
79
|
+
if (predictions is None) == (weights is None):
|
|
80
|
+
raise ValueError("pass exactly one of predictions or weights")
|
|
81
|
+
allow_short = allow_short or rule == "long_short"
|
|
82
|
+
if weights is None:
|
|
83
|
+
predictions, actual = predictions.align(actual, join="inner")
|
|
84
|
+
weights = weights_from_predictions(predictions, actual, rule, quantile)
|
|
85
|
+
else:
|
|
86
|
+
weights, actual = weights.align(actual, join="inner")
|
|
87
|
+
# no position in a name on a day it has no realised return (not a member, not listed)
|
|
88
|
+
weights = weights.fillna(0.0).where(actual.notna(), 0.0)
|
|
89
|
+
if not allow_short and (weights < -1e-12).any().any():
|
|
90
|
+
raise ValueError("weights must be non negative unless allow_short=True")
|
|
91
|
+
if (weights.abs().sum(axis=1) > 1.0 + 1e-9).any():
|
|
92
|
+
raise ValueError("gross exposure must be at most 1 on every day (no leverage)")
|
|
93
|
+
self.actual, self.weights, self.rule = actual, weights, rule
|
|
94
|
+
self.cost = cost_bps / 10_000
|
|
95
|
+
self.borrow = borrow_bps_annual / 10_000 / TRADING_DAYS
|
|
96
|
+
self.capital = capital
|
|
97
|
+
gross = (weights * actual.fillna(0.0)).sum(axis=1)
|
|
98
|
+
turnover = weights.diff().abs().sum(axis=1)
|
|
99
|
+
turnover.iloc[0] = weights.iloc[0].abs().sum()
|
|
100
|
+
short_exposure = weights.clip(upper=0).abs().sum(axis=1)
|
|
101
|
+
self.turnover = turnover
|
|
102
|
+
self.daily_costs = turnover * self.cost + short_exposure * self.borrow
|
|
103
|
+
self.daily_borrow = short_exposure * self.borrow
|
|
104
|
+
self.daily_returns = gross - self.daily_costs
|
|
105
|
+
self.equity = capital * (1 + self.daily_returns).cumprod()
|
|
106
|
+
self.drawdown = self.equity / self.equity.cummax() - 1.0
|
|
107
|
+
|
|
108
|
+
def stats(self) -> dict:
|
|
109
|
+
from .score import sharpe, max_drawdown
|
|
110
|
+
r = self.daily_returns
|
|
111
|
+
years = len(r) / TRADING_DAYS
|
|
112
|
+
total = self.equity.iloc[-1] / self.capital - 1
|
|
113
|
+
return {
|
|
114
|
+
"days": int(len(r)),
|
|
115
|
+
"rule": self.rule,
|
|
116
|
+
"total_return": float(total),
|
|
117
|
+
"annualized_return": float((1 + total) ** (1 / years) - 1) if years > 0 else float("nan"),
|
|
118
|
+
"annualized_vol": float(r.std() * np.sqrt(TRADING_DAYS)),
|
|
119
|
+
"net_sharpe": sharpe(r),
|
|
120
|
+
"max_drawdown": max_drawdown(self.equity),
|
|
121
|
+
"avg_exposure": float(self.weights.sum(axis=1).mean()),
|
|
122
|
+
"avg_gross_exposure": float(self.weights.abs().sum(axis=1).mean()),
|
|
123
|
+
"avg_short_exposure": float(self.weights.clip(upper=0).abs().sum(axis=1).mean()),
|
|
124
|
+
"avg_positions": float((self.weights != 0).sum(axis=1).mean()),
|
|
125
|
+
"annual_turnover": float(self.turnover.sum() / years) if years > 0 else float("nan"),
|
|
126
|
+
"total_costs": float((self.daily_costs * self.equity.shift(1).fillna(self.capital)).sum()),
|
|
127
|
+
"total_borrow": float((self.daily_borrow * self.equity.shift(1).fillna(self.capital)).sum()),
|
|
128
|
+
"dollar_pnl": float(self.equity.iloc[-1] - self.capital),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def monthly_returns(self) -> pd.Series:
|
|
132
|
+
return (1 + self.daily_returns).resample("ME").prod() - 1
|