File size: 11,589 Bytes
134649f
 
 
ea01671
 
 
134649f
 
 
 
ea01671
 
134649f
 
 
 
 
 
 
 
261cef9
ea01671
 
134649f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea01671
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134649f
ea01671
134649f
ea01671
134649f
 
 
 
 
2071eed
 
 
 
 
 
 
 
 
134649f
 
 
2071eed
 
134649f
2071eed
 
 
91d902c
 
 
 
 
 
 
 
 
 
 
261cef9
 
 
91d902c
 
 
 
 
 
 
 
 
 
 
 
 
ea01671
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2071eed
91d902c
ea01671
2071eed
 
ea01671
134649f
2071eed
ea01671
 
 
91d902c
134649f
ea01671
 
 
91d902c
 
134649f
 
 
 
 
 
 
 
 
 
 
 
ea01671
134649f
 
 
ea01671
 
2071eed
134649f
2071eed
 
 
 
 
 
 
 
 
 
 
 
 
134649f
 
2071eed
134649f
 
 
ea01671
134649f
 
 
ea01671
 
134649f
 
 
 
2071eed
261cef9
 
134649f
 
 
ea01671
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134649f
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
"""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))