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:
2026-09-29 01:03:50 +08:00
parent 38a55e7882
commit 2c19e5efe5
16 changed files with 1857 additions and 7 deletions

View File

@@ -124,18 +124,46 @@ def select_top(score_row: np.ndarray, top_n: int, held: np.ndarray | None = None
return np.array(chosen, int)
def zvol_weights(score_row: np.ndarray, idx: np.ndarray, vol: np.ndarray,
lo: float = 0.5, hi: float = 2.0) -> np.ndarray:
"""Grinold (1994) alpha = vol x IC x z, so mean-variance weights with a
diagonal risk model are proportional to z / vol. z is the normal score of
the name's cross-sectional percentile; weights are clipped to
[lo/N, hi/N] and renormalized."""
from scipy.stats import norm
valid = ~np.isnan(score_row)
pct = np.full(len(score_row), np.nan)
order = np.argsort(score_row[valid], kind="stable")
ranks = np.empty(valid.sum()); ranks[order] = (np.arange(valid.sum()) + 0.5) / valid.sum()
pct[valid] = ranks
z = norm.ppf(pct[idx])
v = np.where(vol > 0, vol, np.nan)
v = np.nan_to_num(v, nan=np.nanmedian(v) if np.isfinite(v).any() else 1.0)
raw = np.clip(z, 0.01, None) / v
n = len(idx)
w = raw / raw.sum()
for _ in range(5):
w = np.clip(w, lo / n, hi / n)
w = w / w.sum()
return w
def simulate(panel: Panel, score: pd.DataFrame | None = None, top_n: int = 10,
every: int | str = 5, offset: int = 0, warmup: int = 0,
weighting: str = "equal", buffer: float = 0.0,
exposure: pd.Series | None = None, exec_at: str = "open",
target_weights: pd.DataFrame | None = None, min_trade: float = 0.002,
name: str = "") -> Result:
dyn_n: tuple[int, float] | None = None, name: str = "") -> Result:
"""Run a long-only top-N strategy.
score : higher = better, evaluated at each signal date's close.
target_weights : alternative to score — explicit weights per signal date
(rows need only exist on signal dates; NaN rows skip).
weighting : "equal" | "invvol" (60-day vol) | "score" (score-proportional, clipped >0)
| "zvol" (normal score / 60-day vol, see ``zvol_weights``)
dyn_n : (min_n, threshold): hold every member whose score is at least
``threshold`` (e.g. a mean factor percentile of 0.8), at least
``min_n`` and at most ``top_n`` names.
exposure : fraction of equity to invest, read on the signal date
(e.g. a market-trend or vol-target overlay); rest earns cash_ret.
exec_at : "open" → trade at next day's open; "close" → next day's close.
@@ -159,7 +187,7 @@ def simulate(panel: Panel, score: pd.DataFrame | None = None, top_n: int = 10,
TW = (target_weights.reindex(index=dates, columns=panel.close.columns).values
if target_weights is not None else None)
expo = exposure.reindex(dates).ffill().fillna(1.0).values if exposure is not None else np.ones(n_days)
if weighting == "invvol":
if weighting in ("invvol", "zvol"):
rets = panel.close.pct_change(fill_method=None)
vol = rets.rolling(60, min_periods=40).std().values
sig_mask = rebalance_dates(dates, every, offset, start=warmup)
@@ -231,10 +259,15 @@ def simulate(panel: Panel, score: pd.DataFrame | None = None, top_n: int = 10,
else:
row = np.where(member[d], S[d], np.nan)
held = h > 1e-12
idx = select_top(row, top_n, held, buffer)
n_pick = top_n
if dyn_n is not None:
n_pick = int(np.clip((row >= dyn_n[1]).sum(), dyn_n[0], top_n))
idx = select_top(row, n_pick, held, buffer)
w = np.zeros(n_assets)
if len(idx) >= min(max(1, top_n // 2), 20):
if weighting == "invvol":
if len(idx) >= min(max(1, n_pick // 2), 20):
if weighting == "zvol":
w[idx] = zvol_weights(row, idx, vol[d, idx])
elif weighting == "invvol":
iv = 1.0 / np.where(vol[d, idx] > 0, vol[d, idx], np.nan)
iv = np.nan_to_num(iv, nan=np.nanmean(iv) if np.isfinite(iv).any() else 1.0)
w[idx] = iv / iv.sum()