Files
quant-trading/research/lab/overlays.py
Gahow Wang 2c19e5efe5 feat: US 3-sleeve portfolio and lowrisk_top20; US portfolio research harness
Register two live strategies from the 2026-09-29 US research:
- lowrisk_top20: idiosyncratic vol + low beta, top 20, monthly, buffer 2
  (new `low_risk` signal in strategies/robust_momentum.py)
- us_three_sleeve: 1/3 each of overnight_recovery_top10, lowrisk_top20 and
  trend_rider_v7_vt24 in one account (strategies/sleeve_portfolio.py, a
  mixed stock+ETF strategy). Sleeve shares drift with their returns and reset
  at a month start once any is >25% off its share.

Research (research/lab, S&P 500 PIT, next-open fills):
- rotation.py / persistence.py: walk-forward factor weighting (factor
  momentum, rolling IC, market state) and last-year's-winner selection do not
  beat a fixed composite
- us_sweep.py: holdings count, z/vol weights, dynamic N, tranching and market
  overlays (Faber SMA, de-risk vol target, statistical jump model,
  momentum state); engine.py gains zvol weighting and dyn_n
- fetch_us_fundamentals.py / fundamentals.py / fund_eval.py: SEC EDGAR
  point-in-time fundamentals (first-filed values) and factor tests
- trend_rider_long.py / us_final.py: 2010-2026 TrendRider curves and the
  3-sleeve check (2011-2026 Sharpe 1.21, CAGR 21.9%, MaxDD -23%)

Web-UI engine, 2017-09..2026-09, $2/order: us_three_sleeve CAGR 28.9%,
Sharpe 1.49, MaxDD -24.8%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:03:50 +08:00

225 lines
9.6 KiB
Python

"""
Exposure overlays: series in [0, 1] read on signal dates by engine.simulate
(``exposure=``). The un-invested part earns the panel's cash return.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from research.lab.engine import simulate
def ew_index(p) -> pd.Series:
"""Equal-weight index of PIT members (daily rebalanced)."""
r = p.close.pct_change(fill_method=None).where(p.member.shift(1, fill_value=False))
return (1 + r.mean(axis=1).fillna(0)).cumprod()
def trend(level: pd.Series, ma: int = 200, floor: float = 0.0) -> pd.Series:
"""1 when the index is above its moving average, else ``floor``."""
above = level > level.rolling(ma, min_periods=ma).mean()
return above.astype(float).where(above, floor)
def breadth(p, ma: int = 200, lo: float = 0.4, hi: float = 0.6) -> pd.Series:
"""Share of members above their own MA mapped linearly from lo→0 to hi→1."""
c = p.close.ffill()
above = (c > c.rolling(ma, min_periods=ma).mean()).where(p.member)
share = above.mean(axis=1)
return ((share - lo) / (hi - lo)).clip(0, 1)
def drawdown_brake(level: pd.Series, dd: float = 0.15, ma: int = 50) -> pd.Series:
"""Cut to zero when the index is > dd below its 1y high AND below its MA."""
high = level.rolling(252, min_periods=60).max()
bad = (level / high - 1 < -dd) & (level < level.rolling(ma, min_periods=ma).mean())
return (~bad).astype(float)
def vol_target(p, score, target: float, lookback: int = 60, cap: float = 1.0,
base_kwargs: dict | None = None, exposure: pd.Series | None = None) -> pd.Series:
"""Scale exposure by target / realized vol of the unscaled strategy.
Two passes: run the strategy fully invested, measure its trailing
daily-return vol, then size the next rebalance; any given ``exposure``
(e.g. a trend filter) multiplies the result.
"""
kw = dict(base_kwargs or {})
base = simulate(p, score, **kw)
rv = base.returns.rolling(lookback, min_periods=lookback // 2).std() * np.sqrt(252)
scale = (target / rv).clip(upper=cap).fillna(cap)
if exposure is not None:
scale = scale * exposure.reindex(scale.index).ffill().fillna(1.0)
return scale
# ---------------------------------------------------------------------------
# Literature overlays on an index level (all read at the close of the signal
# date; engine.simulate applies them at the next rebalance)
# ---------------------------------------------------------------------------
def faber_sma(level: pd.Series, months: int = 10) -> pd.Series:
"""Faber (2007): invested while the month-end close is above its
``months``-month simple average; the state changes only at month ends."""
m = level.resample("ME").last()
on = (m > m.rolling(months, min_periods=months).mean()).astype(float)
on = on.where(m.rolling(months).count() >= months, 1.0)
return on.reindex(level.index, method="ffill").fillna(1.0)
def derisk_vol(level: pd.Series, halflife: int = 20, min_hist: int = 252) -> pd.Series:
"""De-risk-only volatility target (Harvey et al. 2018 estimator): exposure
= min(1, median vol to date / EWMA vol), so it only ever cuts."""
r = level.pct_change(fill_method=None).fillna(0.0)
v = np.sqrt((r ** 2).ewm(halflife=halflife, min_periods=halflife).mean()) * np.sqrt(252)
med = v.expanding(min_periods=min_hist).median()
return (med / v).clip(upper=1.0).fillna(1.0)
def momentum_state(level: pd.Series, months: int = 24, cut: float = 0.5) -> pd.Series:
"""Daniel & Moskowitz (2016) / Cooper et al. (2004): after a negative
``months``-month market return momentum is crash-prone; hold ``cut``."""
m = level.resample("ME").last()
down = (m / m.shift(months) - 1) < 0
return down.map({True: cut, False: 1.0}).reindex(level.index, method="ffill").fillna(1.0)
# --- statistical jump model (Shu, Yu & Mulvey 2024, arXiv:2402.05272) -------
def jm_features(r: pd.Series) -> pd.DataFrame:
"""EWM downside deviation (halflife 10) and EWM Sortino ratios
(halflives 20 and 60) of daily excess returns."""
feats = {}
dd10 = np.sqrt((np.minimum(r, 0) ** 2).ewm(halflife=10).mean())
feats["dd10"] = np.log(dd10 + 1e-8)
for hl in (20, 60):
mu = r.ewm(halflife=hl).mean()
dd = np.sqrt((np.minimum(r, 0) ** 2).ewm(halflife=hl).mean())
feats[f"sortino{hl}"] = (mu / (dd + 1e-8)).clip(-10, 10)
return pd.DataFrame(feats)
try: # the DP loops are ~50x faster compiled
from numba import njit
except ImportError: # pragma: no cover - pure-Python fallback
def njit(f=None, **kw):
return f if f is not None else (lambda g: g)
@njit(cache=True)
def _jm_viterbi(cost: np.ndarray, lam: float) -> np.ndarray:
T = cost.shape[0]
V0, V1 = cost[0, 0], cost[0, 1]
back = np.zeros((T, 2), np.int64)
for t in range(1, T):
a0, b0 = V0, V1 + lam # into state 0: stay / switch
a1, b1 = V1, V0 + lam
back[t, 0] = 0 if a0 <= b0 else 1
back[t, 1] = 1 if a1 <= b1 else 0
V0, V1 = cost[t, 0] + min(a0, b0), cost[t, 1] + min(a1, b1)
s = np.empty(T, np.int64)
s[T - 1] = 0 if V0 <= V1 else 1
for t in range(T - 1, 0, -1):
s[t - 1] = back[t, s[t]]
return s
@njit(cache=True)
def _jm_forward(cost: np.ndarray, lam: float) -> np.ndarray:
"""State at each t from a forward DP over data through t (no look-ahead)."""
T = cost.shape[0]
V0, V1 = cost[0, 0], cost[0, 1]
out = np.empty(T, np.int64)
out[0] = 0 if V0 <= V1 else 1
for t in range(1, T):
V0, V1 = cost[t, 0] + min(V0, V1 + lam), cost[t, 1] + min(V1, V0 + lam)
out[t] = 0 if V0 <= V1 else 1
return out
def _jm_cost(X: np.ndarray, mu: np.ndarray) -> np.ndarray:
return ((X[:, None, :] - mu[None]) ** 2).sum(axis=2)
def _jm_fit(X: np.ndarray, lam: float, n_init: int = 6, iters: int = 30, seed: int = 0):
"""Two-state jump model: min sum ||x_t - mu_{s_t}||^2 + lam * [s_t != s_{t-1}]
by coordinate descent (dynamic programming for states, means for centers)."""
rng = np.random.default_rng(seed)
best = (np.inf, None)
for _ in range(n_init):
mu = X[rng.choice(len(X), 2, replace=False)]
for _ in range(iters):
s = _jm_viterbi(_jm_cost(X, mu), lam)
new = np.array([X[s == k].mean(axis=0) if (s == k).any() else mu[k] for k in (0, 1)])
if np.allclose(new, mu):
break
mu = new
s = _jm_viterbi(_jm_cost(X, mu), lam)
obj = _jm_cost(X, mu)[np.arange(len(X)), s].sum() + lam * np.abs(np.diff(s)).sum()
if obj < best[0]:
best = (obj, mu.copy())
return best[1]
def _jm_online(X: np.ndarray, mu: np.ndarray, lam: float) -> np.ndarray:
return _jm_forward(_jm_cost(X, mu), lam)
def jump_model(level: pd.Series, cash_ret: pd.Series | None = None,
lams: tuple[float, ...] = (0.0, 5.0, 10.0, 20.0, 30.0, 50.0, 80.0, 120.0),
train_days: int = 3000, min_train: int = 1500, refit_months: int = 6,
select_years: int = 8, min_select_years: int = 4, cost: float = 0.001) -> pd.Series:
"""0/1 exposure from the jump model. Centers are refit every
``refit_months`` on the trailing ``train_days`` (at least ``min_train``);
features are standardized with the training window's moments; the bull
state is the one with the higher mean return in training. Each month the
jump penalty is re-chosen as the one whose online 0/1 strategy (1-day
delay, ``cost`` per switch) had the best Sharpe over the trailing
``select_years`` (at least ``min_select_years``)."""
r = level.pct_change(fill_method=None).fillna(0.0)
cr = cash_ret.reindex(r.index).fillna(0.0) if cash_ret is not None else pd.Series(0.0, index=r.index)
ex = r - cr
F = jm_features(ex)
dates = r.index
refits = [d for d in pd.date_range(dates[0], dates[-1], freq=f"{refit_months}MS")
if dates.searchsorted(d) >= min_train]
states = {}
for lam in lams:
st = pd.Series(np.nan, index=dates)
for i, d in enumerate(refits):
e = dates.searchsorted(d)
s0 = max(0, e - train_days)
Xtr = F.iloc[s0:e]
m, sd = Xtr.mean(), Xtr.std().replace(0, 1)
mu = _jm_fit(((Xtr - m) / sd).values, lam)
nxt = dates.searchsorted(refits[i + 1]) if i + 1 < len(refits) else len(dates)
# run the forward DP from the window start so the state is warm
Xall = ((F.iloc[s0:nxt] - m) / sd).values
path = _jm_online(Xall, mu, lam)
tr_states = path[:e - s0]
bull = int(np.argmax([ex.iloc[s0:e][tr_states == k].mean() if (tr_states == k).any() else -1
for k in (0, 1)]))
st.iloc[e:nxt] = (path[e - s0:] == bull).astype(float)
states[lam] = st
S = pd.DataFrame(states)
# returns of each penalty's 0/1 strategy, held from the next day
pos = S.shift(1)
strat = pos.mul(r, axis=0) + (1 - pos).mul(cr, axis=0) - pos.diff().abs() * cost
month_ends = strat.resample("ME").last().index
choice = pd.Series(np.nan, index=dates)
for me in month_ends:
hist = strat.loc[:me].dropna()
hist = hist[hist.index > me - pd.DateOffset(years=select_years)]
if len(hist) < 252 * min_select_years:
continue
sh = (hist.mean() / hist.std()).dropna()
if sh.empty:
continue
lam = sh.idxmax()
nxt = dates.searchsorted(me, side="right")
end = dates.searchsorted(me + pd.offsets.MonthEnd(1), side="right")
choice.iloc[nxt:end] = S[lam].iloc[nxt:end].values
return choice.fillna(1.0)