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")