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.
@@ -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.
@@ -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