Files
quant-trading/research/lab/turn_of_month.py
Gahow Wang 65b8db7a12 research: 50%-target search — conservative book, daily quick checks, Alpaca intraday
- managed_futures_book: three sleeves + managed futures (DBMF, tsmom24 proxy
  before 2019-05) cuts MaxDD -37% -> -27% but CAGR 16-19%, Sharpe unchanged
  (REJECT).
- small_cap_reversal / turn_of_month / short_vol: preregistered quick checks,
  all STOP (net of spread costs, post-publication decay, post-2013 VRP 0.37).
- fetch_alpaca: resume-safe Alpaca SIP intraday bars 2016+ (1-min ETFs,
  5-min liquid S&P 1500), keys from .env and never logged.
- intraday_momentum: the SPY noise-area strategy replicates (yearly corr
  1.00 with the paper) but SPY Sharpe 0.99, ~0 after publication, and only
  QQQ of five other ETFs works (STOP).
- orb_stocks_in_play: preregistered 5-min ORB replication (not yet run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:47 -07:00

156 lines
7.4 KiB
Python

"""
Quick check: does the turn-of-the-month effect (Ariel 1987; Lakonishok &
Smidt 1988; McConnell & Xu 2008) still hold after publication, and is it
strong enough to carry leverage (3x the S&P 500 for ~4 days a month, T-bills
otherwise)?
PYTHONPATH=. uv run python -m research.lab.turn_of_month
Data: S&P 500 price index (^GSPC) from 1928 (no dividends; ~2%/yr spread
evenly, so it barely moves a 4-day vs 17-day comparison), the 3-month T-bill
from FRED DTB3 (from 1954; 0 before).
Window (fixed in the literature, not tuned): the last trading day of a month
and the first three of the next, i.e. bought at the close of day -2 and sold
at the close of day +3. The calendar is known in advance, so close fills
are executable.
tom_1x S&P 500 in the window, T-bills otherwise
tom_3x 3x daily S&P 500 in the window: 3 r - 2 rf - 0.9%/yr fee (a UPRO-like
fund), T-bills otherwise
Periods: 1928-1987 (before publication), 1988-2007 (after), 2008-2026 (after
McConnell & Xu, the most recent).
Reported only: the pre-holiday day (Ariel 1990), inferred from a weekday gap.
The rule is written to prereg.json before the run; the script refuses to
overwrite results.json. The project has not tested calendar effects before.
Result (2026-10-08): STOP. C1, C2 and C3 all fail.
- In-window minus out-of-window excess return:
- 1928-87: 15.8 bp a day (t 6.3); the window earned 2.1x all the excess.
- 1988-2007: 9.2 bp (t 2.6).
- 2008-26: 1.2 bp (t 0.27).
- 1988-2026 pooled: t 1.86.
- t by decade: 1950s 5.8, 1960s 3.5, 1990s 1.4, 2000s 0.7, 2010s 0.2, 2020s 0.5.
- tom_3x 2008-26: 5.0%/yr, Sharpe 0.27, MaxDD -47%. SPX buy-and-hold: 9.3% / 0.48.
- Pre-holiday 2008-26: +16.6 bp (t 1.9), reported only, about 9 days a year.
Interpretation: a textbook post-publication decay. Little of the premium
is left to lever.
"""
from __future__ import annotations
import json
import os
import pickle
import numpy as np
import pandas as pd
from research.lab.factor_scan import RESULTS
from research.lab.levered_book import stats, tbill
OUT = os.path.join(RESULTS, "turn_of_month")
PERIODS = {"1928-1987": ("1928-01-01", "1987-12-31"), "1988-2007": ("1988-01-01", "2007-12-31"),
"2008-2026": ("2008-01-01", "2026-09-24")}
POST = ("1988-01-01", "2026-09-24")
FEE3 = 0.009
PREREG = {
"question": "Does the turn-of-month premium survive publication and carry 3x leverage?",
"window": "trading days -1, +1, +2, +3 around each month end (bought at the close of day -2)",
"continue_if_all": [
"C1 1988-2026: mean daily excess return in-window minus out-of-window, Welch t >= 2.0",
"C2 2008-2026: the same difference > 0 with t >= 1.5",
"C3 2008-2026: tom_3x excess Sharpe >= 0.5",
],
"decision": {"CONTINUE": "preregister a TOM leverage overlay on the book (lever only in the window)",
"STOP": "drop calendar effects"},
}
def welch(a: pd.Series, b: pd.Series) -> tuple[float, float]:
d = a.mean() - b.mean()
se = np.sqrt(a.var() / len(a) + b.var() / len(b))
return float(d), float(d / se)
def tom_mask(idx: pd.DatetimeIndex) -> pd.Series:
"""True on days -1, +1, +2, +3 of each month (return days, close-to-close)."""
m = idx.to_period("M")
s = pd.Series(m, index=idx)
pos = s.groupby(s).cumcount() + 1 # +1, +2, ... from month start
neg = s.groupby(s).cumcount(ascending=False) + 1 # 1 on the last day
return pd.Series((pos <= 3).to_numpy() | (neg == 1).to_numpy(), index=idx)
def main() -> None:
os.makedirs(OUT, exist_ok=True)
res_path = os.path.join(OUT, "results.json")
if os.path.exists(res_path):
raise SystemExit(f"{res_path} exists; this study does not overwrite results")
with open(os.path.join(OUT, "prereg.json"), "w") as fh:
json.dump(PREREG, fh, indent=2)
with open(os.path.join("data", "research", "long_history.pkl"), "rb") as fh:
px = pickle.load(fh)["close"]["^GSPC"].dropna()
px = px.loc[:"2026-09-24"]
r = px.pct_change().dropna()
idx = r.index
rf = tbill(idx).fillna(0.0)
ex = r - rf / 252
tom = tom_mask(idx)
gap = pd.Series(idx, index=idx).shift(-1) - pd.Series(idx, index=idx)
pre_hol = (gap > pd.Timedelta(days=1)) & (pd.Series(idx.dayofweek, index=idx) < 4) # next day skipped mid-week
pre_hol |= (gap > pd.Timedelta(days=3))
tom_1x = rf / 252 + ex.where(tom, 0.0)
tom_3x = rf / 252 + (3 * ex - FEE3 / 252).where(tom, 0.0)
spx_3x = rf / 252 + 3 * ex - FEE3 / 252
res = {"share_in_window": float(tom.mean()), "periods": {}}
print(f"share of days in window {tom.mean():.2%}")
for name, (a, b) in {**PERIODS, "1988-2026": POST}.items():
e, t = ex.loc[a:b], tom.loc[a:b]
d, tt = welch(e[t], e[~t])
dh, th = welch(e[pre_hol.loc[a:b] & ~t], e[~pre_hol.loc[a:b] & ~t])
row = {"in_bp": float(e[t].mean() * 1e4), "out_bp": float(e[~t].mean() * 1e4), "diff_bp": d * 1e4,
"t": tt, "share_of_total_excess": float(e[t].sum() / e.sum()) if e.sum() > 0 else float("nan"),
"pre_holiday_diff_bp": dh * 1e4, "pre_holiday_t": th}
for k, s in (("tom_1x", tom_1x), ("tom_3x", tom_3x), ("spx_1x", r), ("spx_3x", spx_3x)):
st = stats(s.loc[a:b], rf.loc[a:b])
row[k] = {"cagr": float(st["cagr"]), "sharpe": float(st["sharpe"]), "mdd": float(st["mdd"]),
"worst_yr": float(st["worst_yr"])}
res["periods"][name] = row
print(f"{name}: in {row['in_bp']:5.1f} bp/d out {row['out_bp']:5.1f} diff {row['diff_bp']:5.1f} t {tt:4.2f} "
f"| share of excess {row['share_of_total_excess']:.2f} | pre-hol diff {row['pre_holiday_diff_bp']:5.1f} t {th:4.2f}")
for k in ("tom_1x", "tom_3x", "spx_1x", "spx_3x"):
s = row[k]
print(f" {k:7s} cagr {s['cagr']*100:5.1f}% xsh {s['sharpe']:5.2f} mdd {s['mdd']*100:6.1f}% "
f"worst yr {s['worst_yr']*100:6.1f}%")
by_dec = ex.groupby(idx.year // 10 * 10).apply(lambda e: welch(e[tom.loc[e.index]], e[~tom.loc[e.index]])[1])
print("t by decade:", by_dec.round(2).to_dict())
res["t_by_decade"] = {int(k): float(v) for k, v in by_dec.items()}
try:
S = pd.read_pickle(os.path.join(RESULTS, "stream_portfolio", "streams.pkl"))
m = lambda x: (1 + x.dropna()).resample("ME").prod() - 1
res["corr_trend"] = float(m(tom_3x.loc["2006-10":]).corr(m(S["trend"])))
print(f"monthly corr tom_3x vs TrendRider V10: {res['corr_trend']:.2f}")
except FileNotFoundError:
pass
p1, p2 = res["periods"]["1988-2026"], res["periods"]["2008-2026"]
checks = {"C1": p1["t"] >= 2.0, "C2": p2["diff_bp"] > 0 and p2["t"] >= 1.5, "C3": p2["tom_3x"]["sharpe"] >= 0.5}
res["verdict"] = {"checks": checks, "decision": "CONTINUE" if all(checks.values()) else "STOP"}
print(f"checks {checks} -> {res['verdict']['decision']}")
with open(res_path, "w") as fh:
json.dump(res, fh, indent=2)
pd.DataFrame({"tom_1x": tom_1x, "tom_3x": tom_3x, "in_window": tom}).to_pickle(os.path.join(OUT, "returns.pkl"))
from research.lab.trials import append_trials
append_trials(os.path.join(RESULTS, "trials_us.jsonl"),
[{"name": f"turn_of_month:{p}:{k}", **res["periods"][p][k]} for p in res["periods"]
for k in ("tom_1x", "tom_3x")],
{"study": "turn_of_month", "role": "preregistered quick check"})
if __name__ == "__main__":
main()