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