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>
This commit is contained in:
@@ -53,3 +53,172 @@ def vol_target(p, score, target: float, lookback: int = 60, cap: float = 1.0,
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user