slipstream / book.py
s-ttp's picture
Upload folder using huggingface_hub
261cef9 verified
Raw History Blame Contribute Delete
11.6 kB
"""Slipstream — target-book computation (runs on the HF Space).
Portable (pandas/numpy only): reads the shipped bundle in data/ and reproduces the
Slipstream rule — regime overlay + dynamic sector cap — so momentum and sector strength are
computed on the Space at rebalance time. The regime test additionally extends SPY with live
Alpaca closes, so the single biggest switch the fund makes never lags the bundle.
"""
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
import numpy as np
import pandas as pd
DATA = Path(__file__).resolve().parent / "data"
TOPN, NAME_CAP, MOM_N = 20, 5, 50
CAP_BASE, CAP_LO, CAP_HI = 0.30, 0.15, 0.45
MAX_POS_W = 0.10 # max weight in any ONE position (v2, Oct 2026: Study 7 — same Sharpe as 15%, top-5 48% vs 56%)
MIN_POS_W = 0.01 # min weight in any one position (see _name_floor)
_CACHE_TTL_S = 15 * 60
def _load():
cand = pd.read_parquet(DATA / "candidates.parquet")
px = pd.read_parquet(DATA / "prices.parquet")
meta = json.loads((DATA / "meta.json").read_text())
wide = px.pivot_table(index="date", columns="ticker", values="px", aggfunc="last").sort_index()
wide.index = pd.to_datetime(wide.index)
return cand, wide, meta
def _dyn_caps(wide, sectors, universe):
"""30% ± each sector's trailing 12m-1m relative strength vs SPY, clipped [15,45]%."""
if "SPY" not in wide or len(wide) < 260:
return {}
secs = sorted(set(sectors.get(t, "?") for t in universe) - {"?"})
sret = {s: wide[[t for t in universe if sectors.get(t) == s and t in wide.columns]]
.pct_change(fill_method=None).mean(axis=1) for s in secs}
sidx = (1 + pd.DataFrame(sret).fillna(0)).cumprod()
spyidx = (1 + wide["SPY"].pct_change(fill_method=None).fillna(0)).cumprod()
rs = (sidx.iloc[-21] / sidx.iloc[-252] - 1) - (spyidx.iloc[-21] / spyidx.iloc[-252] - 1)
return {s: float(np.clip(CAP_BASE + rs.get(s, 0.0), CAP_LO, CAP_HI)) for s in secs}
def _apply_sector_cap(w, sectors, caps):
sec = pd.Series({t: sectors.get(t, "?") for t in w.index})
held = list(sec.unique())
cv = {s: caps.get(s, CAP_BASE) for s in held}
if sum(cv.values()) < 1.0:
f = 1 / sum(cv.values()); cv = {s: min(1.0, c * f) for s, c in cv.items()}
w = w.copy()
for _ in range(30):
sw = w.groupby(sec).sum()
over = {s: sw[s] for s in sw.index if sw[s] > cv.get(s, 1.0) + 1e-9}
if not over:
break
under = ~sec.isin(list(over)); ex = 0.0
for s, val in over.items():
nm = sec[sec == s].index; ex += w[nm].sum() - cv[s]; w[nm] *= cv[s] / val
if w[under].sum() <= 0:
break
w[under] += ex * w[under] / w[under].sum()
return w / w.sum()
def _spy_live(wide):
"""The bundle's SPY closes, extended with live Alpaca daily bars past the bundle's last
date. Returns (series, through_date_str | None). The rest of the book (momentum, sector
RS) keeps using bundle prices: their 12-1m windows are insensitive to a couple of weeks of
lag, and extending 970 tickers live is not worth the quota. No-op on any failure."""
spx = wide["SPY"].dropna() if "SPY" in wide else pd.Series(dtype=float)
if spx.empty:
return spx, None
try:
import alpaca_client as alpaca
last = spx.index.max()
bars = alpaca.spy_series((last + pd.Timedelta(days=1)).strftime("%Y-%m-%d"))
live = {}
for b in bars:
d = pd.to_datetime(b["t"], utc=True).tz_convert("America/New_York").normalize().tz_localize(None)
if d > last:
live[d] = float(b["c"])
if not live:
return spx, None
ext = pd.Series(live).sort_index()
return pd.concat([spx, ext]), str(ext.index.max().date())
except Exception:
return spx, None
def _regime_on(spx: pd.Series):
"""SPY at/above its 200-day MA now AND ~1 month ago (confirmation) → risk-on."""
if spx is None or len(spx) < 221:
return True
spx = spx.ffill()
now = spx.iloc[-1] >= spx.iloc[-200:].mean()
prev = spx.iloc[-22] >= spx.iloc[-221:-21].mean()
return bool(now and prev)
def _pick_tickers(cand, sectors, n, excluded=frozenset()):
"""Top-n tickers by combined score (≤NAME_CAP/sector), skipping any excluded name — so an
excluded holding is deterministically replaced by the next-ranked candidate."""
picks, sec_ct = [], {}
for _, r in cand.sort_values("score", ascending=False).iterrows():
t = r["ticker"]
if t in excluded:
continue
s = sectors.get(t, "?")
if sec_ct.get(s, 0) >= NAME_CAP:
continue
sec_ct[s] = sec_ct.get(s, 0) + 1
picks.append(t)
if len(picks) >= n:
break
return picks
def _name_cap(w, cap=MAX_POS_W):
"""Clip any single position to `cap`, redistributing the excess pro-rata.
Raw 13F-dollar weighting concentrates the book into a handful of mega-caps (the
live book ran ~78% in its top 5, an effective breadth of ~6 names for a nominal
20-stock portfolio, with the tail rounding to sub-$100 positions). A per-name cap
keeps the dollar-conviction tilt — which the backtest shows is genuinely
informative, equal-weight is materially WORSE — while removing the extreme.
See Quant Model/scripts/weighting_study.py: over 2014-2026 (top-20, monthly,
40bps) every cap from 8-30% beat the uncapped book on Sharpe, a smooth plateau
rather than a single lucky point; 15% gave the best drawdown-adjusted return
(Calmar 0.475 vs 0.387 uncapped, maxDD -35.0% vs -39.5%). Rule v2 (Oct 2026) uses 10%:
on the concentrated-manager + ROA book it kept Sharpe (0.98 vs 0.98, 2016-26) while
cutting the top-5 weight from 56% to 48% (data/research/slipstream/quality, Study 7)."""
for _ in range(50):
over = w > cap + 1e-12
if not over.any():
break
excess = float((w[over] - cap).sum())
w[over] = cap
under = ~over
if w[under].sum() <= 0:
break
w[under] += excess * w[under] / w[under].sum()
return w / w.sum()
def _name_floor(w, floor=MIN_POS_W):
"""Raise any position below `floor` to it, funding the lift pro-rata from the weight the
other names carry ABOVE the floor.
The cap above only truncates the top of the dollar-weighted book. The 13F `size` column
spans ~140× (ONON $0.5B → GOOGL $74B), so the tail still rounded to nothing: the live
2026-09 target had BSX at 0.03%, PODD 0.01%, DINO 0.07% — $14–$71 on a $98k account,
below the executor's $493 min-trade deadband, so those names were never bought and the
"20-stock" fund permanently held 16. A 1% floor ($~1k) is the smallest weight that
clears the deadband with margin at $100k. Not part of weighting_study.py (which only
tested caps); it moves ~3-5% of the book from the top names to the tail."""
for _ in range(50):
under = w < floor - 1e-12
if not under.any():
break
need = float((floor - w[under]).sum())
w[under] = floor
pool = (w[~under] - floor).clip(lower=0)
if pool.sum() <= 0:
break
w[~under] -= need * pool / pool.sum()
return w / w.sum()
def _base_book(cand, wide, sectors, universe, excluded=frozenset()):
"""Top-20 by combined score (≤5/sector, minus excluded), $-weighted with a per-name
cap AND floor, then dynamic sector cap."""
picks = _pick_tickers(cand, sectors, TOPN, excluded)
sel = cand[cand["ticker"].isin(picks)].drop_duplicates("ticker").set_index("ticker").loc[picks]
w = sel["size"].clip(lower=0).astype(float)
if w.sum() <= 0:
w = pd.Series(1.0, index=picks)
# The constraints interact: the sector cap redistributes weight pro-rata and can push a
# name back above MAX_POS_W or below MIN_POS_W. Alternate them until all hold (converges
# in a few passes; the final sector pass is the binding one).
caps = _dyn_caps(wide, sectors, universe)
w = w / w.sum()
for _ in range(20):
w = _apply_sector_cap(_name_floor(_name_cap(w)), sectors, caps)
if w.max() <= MAX_POS_W + 1e-6 and w.min() >= MIN_POS_W - 1e-6:
break
return w
def _momentum_book(wide, universe, sectors):
"""Risk-off sleeve: top-50 large-cap by 12m-1m return, equal-weight."""
if len(wide) < 253:
return pd.Series(dtype=float)
u = [t for t in universe if t in wide.columns]
mom = (wide[u].iloc[-21] / wide[u].iloc[-252] - 1).dropna()
pick = mom.nlargest(MOM_N).index
return pd.Series(1.0 / len(pick), index=pick)
def _compute_book(screen: bool = True) -> dict:
cand, wide, meta = _load()
sectors = meta.get("sectors", {})
universe = meta.get("universe", [])
spx, spy_through = _spy_live(wide)
on = _regime_on(spx)
screen_meta = {"enabled": False, "scanned": 0, "cleared": 0, "excluded": [], "as_of": None}
if on:
# AI event-risk overlay: screen the candidate pool (final 20 + substitutes) for concrete
# adverse events and hard-exclude high/critical; excluded names are replaced by the
# next-ranked candidate inside _base_book. Degrades to no exclusions on any failure.
excluded = set()
if screen:
try:
import risk_screen
pool = _pick_tickers(cand, sectors, TOPN + 6)
scr = risk_screen.screen(pool)
excluded, screen_meta = scr["excluded"], scr["meta"]
except Exception:
pass
w = _base_book(cand, wide, sectors, universe, excluded=excluded)
book = "base"
else:
w = _momentum_book(wide, universe, sectors) # broad 50-name rotation — event screen n/a
book = "momentum"
holdings = [{"ticker": t, "weight": round(float(x), 5), "sector": sectors.get(t, "?")}
for t, x in w.sort_values(ascending=False).items()]
prices_through = str(wide.index.max().date())
return {
"fund": "Slipstream",
"signals_as_of": meta.get("as_of"),
"prices_through": prices_through,
"spy_through": spy_through or prices_through,
"regime": "risk-on · smart-money book" if on else "risk-off · momentum rotation",
"book": book,
"n_holdings": len(holdings),
"holdings": holdings,
"risk_screen": screen_meta,
"rule": "concentrated-manager 13F consensus · top-1000 · quality(ROA top-30% in sector) · top-20 $-wt "
"(1–10%/name) · dynamic sector cap 15–45% · AI critical-event exclusion · regime→momentum (SPY live) · monthly",
}
_cache_lock = threading.Lock()
_cache: dict = {}
def compute_book(screen: bool = True, max_age_s: float = _CACHE_TTL_S) -> dict:
"""Target book, memoised for 15 minutes. Every dashboard load and dry-run preview calls
this on a public Space (a 2 MB parquet pivot + Alpaca + the daily-cached risk screen), and
the inputs only change on deploy or the daily screen — pass max_age_s=0 for the real
rebalance so it always trades the freshest book."""
with _cache_lock:
hit = _cache.get(screen)
if hit and max_age_s > 0 and time.time() - hit[0] < max_age_s:
return hit[1]
result = _compute_book(screen)
with _cache_lock:
_cache[screen] = (time.time(), result)
return result
if __name__ == "__main__":
print(json.dumps(compute_book(), indent=1))