Spaces:
Running
Running
Download book.py from s-ttp/slipstream: direct link, hf CLI and curl.
- Browser
- Download file 11.6 kB
-
https://huggingface.co/spaces/s-ttp/slipstream/resolve/main/book.py
- Command line
-
hf download hf://spaces/s-ttp/slipstream/book.py
-
curl -L -o book.py https://huggingface.co/spaces/s-ttp/slipstream/resolve/main/book.py
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)) | |