Register us_momentum_qqq_trend: half overnight_recovery_top10, half a QQQ trend sleeve (strategies/qqq_trend.py: 200-day switch times a 30% vol target, held as TQQQ + SHY). Mixed stock+ETF, US only (extra tickers QQQ, SHY, TQQQ). Research (research/lab/): alpha against QQQ with a 2006-16 design / 2017-26 holdout split (alpha.py, qqq_scan.py, beat_qqq.py); leverage and drawdown frontier (levered_book.py, us_target50.py); synthetic 3x ETFs from 1990 and TrendRider/QQQ-trend stress tests (fetch_long_history.py, letf_history.py, vol_trend.py); short-horizon sleeves, diversifiers, industry momentum, earnings-announcement returns; new point-in-time data sources: SEC Form 4 insider trades (fetch_insider.py, insider.py), 10-K/10-Q text similarity, 8-K earnings dates and SIC codes (fetch_lazy_prices.py, lazy_prices.py), and an S&P 400/600 universe from iShares holdings (fetch_us_mid.py, panel_mid.py). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
141 lines
6.2 KiB
Python
141 lines
6.2 KiB
Python
"""
|
|
Return sources outside S&P 500 stocks for the US book: multi-asset trend on
|
|
ETFs, and trend on bitcoin. Does either add to the three current sleeves
|
|
(momentum, low risk, TrendRider)?
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.fetch_long_history # once (BTC)
|
|
PYTHONPATH=. uv run python -m research.lab.diversifiers
|
|
|
|
Multi-asset trend (managed futures through ETFs): each asset held while above
|
|
its n-day average, sized to 10% vol (EWMA, 30-day half-life), gross capped at
|
|
1x, the rest in cash at the T-bill rate. ETFs from data/research/tr_long.pkl
|
|
(2008-) and data/us.csv (DBC).
|
|
|
|
Bitcoin trend: long BTC or cash, decided on closes through t-1, traded at the
|
|
close of t, 10 bp per unit traded. Tradable in a US brokerage account through
|
|
spot bitcoin ETFs (IBIT, FBTC; 2024-), before that only GBTC (OTC, premium /
|
|
discount) or futures ETFs (BITO, 2021-, roll cost). The backtest uses BTC-USD
|
|
on US trading days (Yahoo's daily close is 00:00 UTC, ~4-5 hours after the
|
|
US close), so it is a proxy for the ETF.
|
|
sma{n} hold while BTC > its n-day average
|
|
tsmom{n} hold while the n-day return is positive
|
|
vt60 the same, sized to a 60% vol target (cap 1x)
|
|
The whole grid is printed; the median rule is the honest estimate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import pickle
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from research.lab.factor_scan import RESULTS
|
|
from research.lab.levered_book import fmt, stats, tbill
|
|
from research.lab.us_final import monthly_blend
|
|
|
|
warnings.filterwarnings("ignore")
|
|
SRC = os.path.join("data", "research", "long_history.pkl")
|
|
COST = 0.001
|
|
SPAN = ("2011-01-03", "2026-09-24")
|
|
BTC_BLOCKS = [("2015-06-01", "2017-12-31"), ("2018-01-01", "2020-12-31"), ("2021-01-01", "2023-12-31"),
|
|
("2024-01-01", "2026-09-25")]
|
|
THREE = {"momentum": 1 / 3, "lowrisk": 1 / 3, "trend": 1 / 3}
|
|
|
|
|
|
def multi_asset_trend(close: pd.DataFrame, rf: pd.Series, assets: list[str], n: int,
|
|
target: float = 0.10) -> tuple[pd.Series, pd.Series]:
|
|
r = close.pct_change(fill_method=None)
|
|
W = {}
|
|
for a in assets:
|
|
px = close[a]
|
|
on = (px > px.rolling(n).mean()).astype(float)
|
|
vol = r[a].ewm(halflife=30, min_periods=60).std() * np.sqrt(252)
|
|
W[a] = (on * (target / vol).clip(upper=3.0)).shift(1)
|
|
W = pd.DataFrame(W).fillna(0.0)
|
|
W = W.div(W.sum(axis=1).clip(lower=1.0), axis=0)
|
|
cash = 1 - W.sum(axis=1)
|
|
ret = (W * r[assets].fillna(0.0)).sum(axis=1) + cash * rf.reindex(W.index) / 252 \
|
|
- W.diff().abs().sum(axis=1) * COST
|
|
return ret, W.sum(axis=1)
|
|
|
|
|
|
def btc_trend(px: pd.Series, rf: pd.Series, rule: str, n: int,
|
|
vol_target: float | None = None) -> tuple[pd.Series, pd.Series]:
|
|
r = px.pct_change(fill_method=None).fillna(0.0)
|
|
on = px > (px.rolling(n).mean() if rule == "sma" else px.shift(n))
|
|
expo = on.astype(float)
|
|
if vol_target:
|
|
vol = r.ewm(halflife=20, min_periods=40).std() * np.sqrt(252)
|
|
expo = expo * (vol_target / vol).clip(upper=1.0).fillna(0.0)
|
|
expo = expo.shift(1).fillna(0.0)
|
|
out = expo * r + (1 - expo) * rf.reindex(r.index) / 252 - expo.diff().abs().fillna(0.0) * COST
|
|
return out, expo
|
|
|
|
|
|
def with_book(book: pd.DataFrame, name: str, r: pd.Series, rf: pd.Series, start: str) -> None:
|
|
span = book.loc[start:SPAN[1], ["momentum", "lowrisk", "trend"]].fillna(0.0)
|
|
span[name] = r.reindex(span.index).fillna(0.0)
|
|
m = (1 + span).resample("ME").prod() - 1
|
|
print(f"monthly correlation of {name} with the sleeves:",
|
|
m.corr()[name].drop(name).round(2).to_dict())
|
|
print(fmt(f"3 sleeves {start[:4]}-", stats(monthly_blend(span, THREE), rf)))
|
|
for w in (0.1, 0.25):
|
|
mix = {k: v * (1 - w) for k, v in THREE.items()} | {name: w}
|
|
print(fmt(f"3 sleeves + {int(w*100)}% {name}", stats(monthly_blend(span, mix), rf)))
|
|
|
|
|
|
def main() -> None:
|
|
book = pd.read_pickle(os.path.join(RESULTS, "us_final_sleeves.pkl"))
|
|
rf = tbill(book.index.union(pd.bdate_range("2014-01-01", "2026-09-30")))
|
|
|
|
print("=== multi-asset trend on ETFs, 2010-03-")
|
|
with open(os.path.join("data", "research", "tr_long.pkl"), "rb") as fh:
|
|
etf = pickle.load(fh)["close"].ffill()
|
|
etf = etf.join(pd.read_csv(os.path.join("data", "us.csv"), index_col=0, parse_dates=True)[["DBC"]], how="left")
|
|
best = None
|
|
for assets in (["SPY", "TLT", "GLD"], ["TQQQ", "TLT", "GLD"], ["SPY", "TLT", "GLD", "DBC"]):
|
|
for n in (100, 200):
|
|
r, g = multi_asset_trend(etf, rf, assets, n)
|
|
r = r.loc["2010-03-01":]
|
|
s = stats(r, rf)
|
|
print(fmt(f"{'+'.join(assets)} ma{n}", s, g.loc["2010-03-01":]))
|
|
if best is None or s["sharpe"] > best[1]:
|
|
best = (r, s["sharpe"], f"{'+'.join(assets)} ma{n}")
|
|
print(f"best of the grid: {best[2]} (optimistic: picked in sample)")
|
|
with_book(book, "xtrend", best[0], rf, SPAN[0])
|
|
|
|
if not os.path.exists(SRC):
|
|
print("\nno data/research/long_history.pkl: run research.lab.fetch_long_history for the BTC part")
|
|
return
|
|
print("\n=== bitcoin trend, 2015-06-")
|
|
with open(SRC, "rb") as fh:
|
|
src = pickle.load(fh)
|
|
days = src["close"]["SPY"].dropna().loc["2014-12-01":].index
|
|
btc = src["close"]["BTC-USD"].reindex(days, method="ffill").dropna()
|
|
hold = btc.pct_change().fillna(0.0).loc["2015-06-01":]
|
|
print(fmt("hold BTC", stats(hold, rf)))
|
|
rows = {}
|
|
for rule in ("sma", "tsmom"):
|
|
for n in (20, 50, 100, 150, 200):
|
|
for vt in (None, 0.6):
|
|
r, e = btc_trend(btc, rf, rule, n, vt)
|
|
r = r.loc["2015-06-01":]
|
|
key = f"{rule}{n}{' vt60' if vt else ''}"
|
|
rows[key] = r
|
|
bl = " ".join(f"{stats(r.loc[a:b], rf)['sharpe']:5.2f}" for a, b in BTC_BLOCKS)
|
|
print(fmt(key, stats(r, rf), e.loc["2015-06-01":]), f"| blocks {bl}")
|
|
R = pd.DataFrame(rows)
|
|
sh = R.apply(lambda x: stats(x, rf)["sharpe"]).sort_values()
|
|
med = sh.index[len(sh) // 2]
|
|
print(f"median-Sharpe rule: {med} ({sh[med]:.2f}); range {sh.min():.2f}-{sh.max():.2f}")
|
|
with_book(book, "btc", R[med], rf, "2015-06-01")
|
|
with open(os.path.join(RESULTS, "diversifiers.pkl"), "wb") as fh:
|
|
pickle.dump({"btc": R, "btc_median_rule": med, "xtrend": best[0]}, fh)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|