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>
225 lines
9.6 KiB
Python
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)
|