File size: 7,791 Bytes
7ec49f5
 
 
 
 
 
 
a179080
 
ea01671
 
7ec49f5
ea01671
 
 
 
 
 
7ec49f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a179080
7ec49f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Slipstream supervisor β€” daily health & drift monitor (deterministic, flag-only).

Ported from DeepQuant's P4 supervisor, adapted to Slipstream's monthly cadence and caps.
Three checks, each OK / WATCH / ALERT β€” it NEVER trades:

  1. tracking  β€” trailing ~4-week fund-vs-SPY gap. No weekly backtest distribution exists yet
                 for this strategy (only summary stats), so v1 uses static bands: WATCH < -3pp,
                 ALERT < -6pp. (Rule v2's 2014–2026 backtest: excess return +6.0pp/yr, alpha
                 +3.9pp/yr at Ξ² 1.17 β€” a -6pp MONTH is a genuine outlier against a ~+0.5pp/month
                 edge.) Upgrade path: replace bands with the percentile envelope once a weekly
                 walk-forward curve is produced.
  2. guardrailsβ€” paper-endpoint invariant (ALERT if live trading is enabled), no margin
                 (cash >= -0.2% of equity β€” the executor now keeps ~1% cash, so any negative
                 balance is a fault), gross exposure <= 100.5%, and position drift: the 15% name
                 cap applies AT rebalance, so intra-month drift is expected β€” WATCH above
                 cap+3pp (18%), ALERT above cap+7pp (22%).
  3. freshness β€” signals bundle (data/meta.json as_of) <= 10 days old (weekly refresh + slack;
                 the regime test pulls SPY live so it is safe even when the bundle lags);
                 last rebalance order <= 35 days (monthly cadence + slack).

GET /api/supervisor returns the cached report (recomputed if older than TTL or force=1).
"""
from __future__ import annotations

import datetime as dt
import json
import threading
import time
from pathlib import Path

import alpaca_client as alp
import config
from book import MAX_POS_W

_TTL_S = 6 * 3600
_lock = threading.Lock()
_cache: dict = {"ts": 0.0, "report": None}

_DATA = Path(__file__).resolve().parent.parent / "data"


def _check_tracking() -> dict:
    name = "Tracking vs S&P 500"
    try:
        h = alp.nav_history(period="1M", timeframe="1D")
        pts = [(dt.datetime.fromtimestamp(pt["t"], dt.timezone.utc).date(), pt["equity"])
               for pt in (h.get("points") or []) if pt.get("equity")]
        pts = [p for p in pts if (pts[-1][0] - p[0]).days <= 28]
        if len(pts) < 5:
            return {"name": name, "status": "WATCH", "detail": "not enough NAV history yet (<5 days in window)"}
        fund = pts[-1][1] / pts[0][1] - 1.0
        spy_rows = alp.spy_series(start=str(pts[0][0]))
        if len(spy_rows) < 2:
            return {"name": name, "status": "WATCH", "detail": "could not fetch SPY series"}
        spy = spy_rows[-1]["c"] / spy_rows[0]["c"] - 1.0
        gap = fund - spy
        st = "ALERT" if gap < -0.06 else ("WATCH" if gap < -0.03 else "OK")
        return {"name": name, "status": st,
                "detail": (f"trailing-4wk gap {gap*100:+.1f}pp (fund {fund*100:+.1f}% vs SPY {spy*100:+.1f}%) β€” "
                           f"static bands (WATCH <-3pp, ALERT <-6pp); rule-v2 backtest excess ~+6.0pp/yr (2014–2026)")}
    except Exception as e:  # noqa: BLE001
        return {"name": name, "status": "WATCH", "detail": f"could not compute ({type(e).__name__}: {str(e)[:60]})"}


def _check_guardrails() -> dict:
    name = "Guardrails (paper / margin / exposure / caps)"
    try:
        problems, worst = [], "OK"

        def esc(level):
            nonlocal worst
            order = {"OK": 0, "WATCH": 1, "ALERT": 2}
            if order[level] > order[worst]:
                worst = level

        if getattr(config, "ALPACA_ALLOW_LIVE", False):
            problems.append("ALPACA_ALLOW_LIVE is enabled β€” fund must stay paper-only"); esc("ALERT")
        acct = alp.account()
        equity = float(acct.get("equity") or 0.0)
        cash = float(acct.get("cash") or 0.0)
        if equity > 0 and cash < -0.002 * equity:
            problems.append(f"negative cash {cash:,.0f} beyond -0.2% of equity (margin in use)"); esc("ALERT")
        pos = alp.positions()
        gross = sum(abs(float(p.get("market_value") or 0.0)) for p in pos)
        if equity > 0 and gross > 1.005 * equity:
            problems.append(f"gross exposure {gross/equity*100:.1f}% > 100.5%"); esc("ALERT")
        if equity > 0 and pos:
            w = {p["symbol"]: abs(float(p.get("market_value") or 0.0)) / equity for p in pos}
            top_sym = max(w, key=w.get); top_w = w[top_sym]
            if top_w > MAX_POS_W + 0.07:
                problems.append(f"{top_sym} at {top_w*100:.1f}% β€” far above the {MAX_POS_W*100:.0f}% rebalance cap"); esc("ALERT")
            elif top_w > MAX_POS_W + 0.03:
                problems.append(f"{top_sym} drifted to {top_w*100:.1f}% (cap {MAX_POS_W*100:.0f}% applies at rebalance)"); esc("WATCH")
        detail = "; ".join(problems) if problems else (
            f"paper-only βœ“ Β· cash {cash:,.0f} Β· exposure {gross/equity*100:.1f}% Β· "
            f"{len(pos)} positions, largest within drift tolerance")
        return {"name": name, "status": worst, "detail": detail}
    except Exception as e:  # noqa: BLE001
        return {"name": name, "status": "WATCH", "detail": f"could not compute ({type(e).__name__}: {str(e)[:60]})"}


def _check_freshness() -> dict:
    name = "Data & rebalance freshness"
    try:
        today = dt.date.today()
        problems, worst = [], "OK"

        def esc(level):
            nonlocal worst
            order = {"OK": 0, "WATCH": 1, "ALERT": 2}
            if order[level] > order[worst]:
                worst = level

        meta = json.loads((_DATA / "meta.json").read_text())
        as_of = dt.date.fromisoformat(str(meta.get("as_of")))
        age = (today - as_of).days
        if age > 14:
            problems.append(f"signals bundle {age}d old (as_of {as_of}) β€” weekly refresh has missed 2+ cycles"); esc("ALERT")
        elif age > 10:
            problems.append(f"signals bundle {age}d old (as_of {as_of}) β€” weekly refresh may have failed"); esc("WATCH")
        last_reb = alp.last_order_date()
        if last_reb is None:
            problems.append("no orders found β€” fund has never rebalanced"); esc("WATCH")
        else:
            if isinstance(last_reb, str):
                last_reb = dt.date.fromisoformat(last_reb[:10])
            elif isinstance(last_reb, dt.datetime):
                last_reb = last_reb.date()
            rb_age = (today - last_reb).days
            if rb_age > 45:
                problems.append(f"last rebalance {rb_age}d ago β€” monthly cron has missed a cycle"); esc("ALERT")
            elif rb_age > 35:
                problems.append(f"last rebalance {rb_age}d ago (monthly cadence + slack is 35d)"); esc("WATCH")
        detail = "; ".join(problems) if problems else (
            f"signals as_of {as_of} ({age}d) βœ“ Β· last rebalance "
            f"{last_reb if last_reb else 'n/a'} βœ“")
        return {"name": name, "status": worst, "detail": detail}
    except Exception as e:  # noqa: BLE001
        return {"name": name, "status": "WATCH", "detail": f"could not compute ({type(e).__name__}: {str(e)[:60]})"}


def run_checks() -> dict:
    checks = [_check_tracking(), _check_guardrails(), _check_freshness()]
    order = {"OK": 0, "WATCH": 1, "ALERT": 2}
    overall = max((c["status"] for c in checks), key=lambda s: order.get(s, 1))
    return {"overall": overall, "checks": checks,
            "computed_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
            "note": "flag-only monitor β€” it never trades"}


def report(force: bool = False) -> dict:
    with _lock:
        if force or _cache["report"] is None or (time.time() - _cache["ts"]) > _TTL_S:
            _cache["report"] = run_checks()
            _cache["ts"] = time.time()
        return _cache["report"]