Spaces:
Running
Running
File size: 8,185 Bytes
134649f ce24482 134649f cc21234 134649f cc21234 d6eceaf cc21234 d6eceaf 134649f cc21234 8aaff2e b0d998e 134649f 471bad3 134649f 471bad3 134649f 471bad3 134649f 2739178 134649f ea01671 134649f b08ac4c 2739178 ea01671 134649f b08ac4c 134649f 405ceb7 134649f 405ceb7 134649f 471bad3 405ceb7 471bad3 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 | """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")
|