"""Slipstream — HF Space app. Serves the fund dashboard: the Slipstream paper portfolio + performance vs SPY + the current target book, plus the informational "Top AI Picks of the Month" panels. The monthly rebalance is computed on the Space (book.py) and executed to Alpaca paper via a token-guarded, dry-run-by-default endpoint. """ from datetime import datetime, timezone from fastapi import FastAPI, Header, HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from starlette.concurrency import run_in_threadpool import config import alpaca_client as alpaca import book import services.superinvestor as superinvestor import services.insider as insider import services.news as news app = FastAPI(title="Slipstream") PANELS = {"superinvestor": superinvestor.top_picks, "insider": insider.top_picks, "news": news.top_picks} @app.get("/api/health") def health(): return {"fund": "Slipstream", "alpaca_configured": bool(config.ALPACA_API_KEY and config.ALPACA_SECRET_KEY), "alpaca_paper": config.alpaca_is_paper(), "rebalance_enabled": bool(config.REBALANCE_TOKEN)} @app.get("/api/status") async def status(): try: mkt = await run_in_threadpool(alpaca.market_status) return {"market": mkt, "last_order": await run_in_threadpool(alpaca.last_order_date), "rebalanced_this_month": await run_in_threadpool(alpaca.rebalanced_this_month), "rebalance_enabled": bool(config.REBALANCE_TOKEN)} except alpaca.AlpacaError as e: raise HTTPException(503, str(e)) @app.get("/api/fund") async def fund(): try: return await run_in_threadpool(alpaca.portfolio) except alpaca.AlpacaError as e: raise HTTPException(503, str(e)) @app.get("/api/target") async def target(): return await run_in_threadpool(book.compute_book) @app.get("/api/nav") async def nav(): try: from zoneinfo import ZoneInfo ET = ZoneInfo("America/New_York") hist = await run_in_threadpool(alpaca.nav_history) raw = list(hist["points"]) # ── Date-join fund and SPY in MARKET time ────────────────────────────────────── # Alpaca portfolio-history epochs and SPY daily bars carry different timestamp # conventions for the same session, and extending the two lines independently let # them diverge (SPY silently missing the newest point). Join on the ET calendar # date, keep only dates BOTH have, and stamp both at that day's 16:00 ET close — # the chart can then never show skewed or ragged lines. fund_by: dict = {} for pt in raw: d = datetime.fromtimestamp(pt["t"], ET).date() fund_by[d] = pt["equity"] # last write per session wins start = min(fund_by).strftime("%Y-%m-%d") if fund_by else "2024-01-01" spy_raw = await run_in_threadpool(alpaca.spy_series, start) spy_by: dict = {} for b in spy_raw: try: d = datetime.fromisoformat(str(b["t"]).replace("Z", "+00:00")).astimezone(ET).date() spy_by[d] = b["c"] except (ValueError, KeyError): continue days = sorted(set(fund_by) & set(spy_by)) def _close_ts(d): return datetime(d.year, d.month, d.day, 16, 0, tzinfo=ET) pts = [{"t": int(_close_ts(d).timestamp()), "equity": float(fund_by[d])} for d in days] spy = [{"t": _close_ts(d).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "c": float(spy_by[d])} for d in days] # Live "now" extension — appended as a PAIR or not at all, so the lines always # terminate together and match the live Day% KPI during market hours. try: acct = await run_in_threadpool(alpaca.account) eq = acct.get("equity") q = (await run_in_threadpool(alpaca.quotes, ["SPY"])).get("SPY") or {} now_et = datetime.now(ET) if (eq and q.get("price") and days and now_et.date() > days[-1] and (abs(float(eq) - pts[-1]["equity"]) > 0.01)): ts = int(now_et.timestamp()) pts.append({"t": ts, "equity": float(eq)}) spy.append({"t": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "c": float(q["price"])}) except Exception: pass return {"fund": pts, "base_value": hist["base_value"], "spy": spy} except alpaca.AlpacaError as e: raise HTTPException(status_code=502, detail=str(e)) @app.get("/api/supervisor") def api_supervisor(force: int = 0): """Daily health & drift monitor (flag-only, never trades) — tracking vs SPY, guardrails (paper/margin/exposure/caps), data & rebalance freshness.""" from services import supervisor return supervisor.report(force=bool(force)) @app.get("/api/quotes") async def quotes(symbols: str): syms = [s for s in symbols.split(",") if s.strip()][:60] if not syms: raise HTTPException(400, "no symbols") try: return await run_in_threadpool(alpaca.quotes, syms) except alpaca.AlpacaError as e: raise HTTPException(503, str(e)) @app.get("/api/picks/{panel}") async def picks(panel: str, refresh: bool = False, limit: int = 5): if panel not in PANELS: raise HTTPException(404, f"unknown panel {panel!r}") limit = max(1, min(int(limit), 20)) # homepage uses 5; the dedicated pages use 20 try: return await run_in_threadpool(PANELS[panel], refresh, limit) except Exception as e: # noqa: BLE001 — surface data/scrape failures to the UI raise HTTPException(502, str(e)) @app.post("/api/rebalance") async def rebalance(dry: int = 1, force: int = 0, x_rebalance_token: str = Header(default="")): """Rebalance the paper account to the Slipstream target book. Dry-run by default; a real submit needs dry=0 AND the matching REBALANCE_TOKEN. Executes at most once per calendar month (Slipstream is monthly) and only while the market is open — so a dumb daily cron is safe: it no-ops until the first market-open day of the month. `force=1` overrides the once-a-month guard.""" if dry: tgt = (await run_in_threadpool(book.compute_book))["holdings"] # memoised book try: return {"dry_run": True, **await run_in_threadpool(alpaca.plan_rebalance, tgt)} except alpaca.AlpacaError as e: raise HTTPException(503, str(e)) if not config.REBALANCE_TOKEN or x_rebalance_token != config.REBALANCE_TOKEN: raise HTTPException(403, "rebalance disabled or bad token (set REBALANCE_TOKEN, send X-Rebalance-Token)") if not await run_in_threadpool(alpaca.is_market_open): return {"dry_run": False, "executed": False, "reason": "market closed — retry during market hours"} if not force and await run_in_threadpool(alpaca.rebalanced_this_month): return {"dry_run": False, "executed": False, "reason": "already rebalanced this month (force=1 to override)"} tgt = (await run_in_threadpool(book.compute_book, True, 0))["holdings"] # real money: fresh book try: res = await run_in_threadpool(alpaca.execute_rebalance, tgt) return {"dry_run": False, "executed": True, **res} except alpaca.AlpacaError as e: raise HTTPException(400, str(e)) # HTML pages must never be cached stale: browsers held the old page across deploys # (assets are versioned with ?v=, but the HTML that references them was cacheable). _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} @app.get("/") def index(): return FileResponse(config.APP_DIR / "static" / "index.html", headers=_NO_CACHE) @app.get("/picks") def picks_page(): """Dedicated top-20 page for one category (?panel=superinvestor|insider|news).""" return FileResponse(config.APP_DIR / "static" / "picks.html", headers=_NO_CACHE) app.mount("/static", StaticFiles(directory=config.APP_DIR / "static"), name="static")