slipstream / app.py
s-ttp's picture
Claude Opus 5
Fix standing margin balance, restore the 20-name book, harden the panels
ea01671
Raw History Blame Contribute Delete
8.19 kB
"""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")