Spaces:
Running
Running
File size: 7,473 Bytes
134649f d6a28c7 134649f d6a28c7 134649f ce24482 134649f cc21234 134649f cc21234 d6eceaf cc21234 d6eceaf 134649f cc21234 8aaff2e b0d998e 134649f 2739178 134649f ea01671 134649f b08ac4c 2739178 ea01671 134649f b08ac4c 134649f 405ceb7 134649f 405ceb7 134649f 471bad3 d6a28c7 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 | """Slipstream β HF Space app.
Serves the fund dashboard: the Slipstream paper portfolio + performance vs SPY + the
current target book. The monthly rebalance is computed on the Space (book.py) and executed
to Alpaca paper via a token-guarded, dry-run-by-default endpoint.
The informational "Top AI Picks of the Month" panels moved to the Signalflow Space
(SIGNALFLOW_URL); the old /picks page redirects there.
"""
from datetime import datetime, timezone
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
import config
import alpaca_client as alpaca
import book
app = FastAPI(title="Slipstream")
@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.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(panel: str = ""):
"""The top-20 picks pages moved to Signalflow; keep old links working."""
target = config.SIGNALFLOW_URL.rstrip("/") + ("/picks?panel=" + panel if panel.isalpha() else "/")
return RedirectResponse(target, status_code=301)
@app.get("/api/picks/{panel}")
def picks_moved(panel: str):
raise HTTPException(410, f"the picks panels moved to {config.SIGNALFLOW_URL}")
app.mount("/static", StaticFiles(directory=config.APP_DIR / "static"), name="static")
|