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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user