"""Momentum ADMIN panel (:8767) — configure, validate, and control the stack. LAN-only by intent; every mutating endpoint (and the page itself) requires the ADMIN_TOKEN (env). Capabilities: - service health: docker containers (via /var/run/docker.sock), DB, Alpaca, Ollama, loop heartbeat, market clock, data feed - config: read/edit the whitelisted strategy knobs via data/overrides.env (beats compose env on next boot; see runtime/env_overrides.py); secrets are shown as present/absent only, never values - profiles: named knob presets (data/profiles/*.json) applied in one click - restart: bounce individual containers so config changes take effect - paper/live switch: guarded — refuses unless ALPACA_LIVE_* keys are set Run: python admin_api.py (ADMIN_HOST/ADMIN_PORT, default 0.0.0.0:8767) """ from __future__ import annotations import hmac import http.client import json import os import socket import urllib.request from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from dotenv import load_dotenv load_dotenv() from runtime.env_overrides import load as _load_overrides # noqa: E402 _load_overrides() from runtime import env_overrides # noqa: E402 DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock") PROFILES_DIR = os.environ.get( "PROFILES_DIR", os.path.join(os.path.dirname(__file__), "data", "profiles")) ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") # knobs the UI may read+write — everything else is off-limits by construction EDITABLE_PREFIXES = ("TRADING_", "TRIGGER_", "WATCHER_", "LIVE_BARS_", "NEWS_", "OLLAMA_", "DISCOVER_", "SYMBOL_WEIGHTS") EDITABLE_EXACT = {"ALPACA_DATA_FEED"} SECRET_MARKERS = ("KEY", "SECRET", "TOKEN", "PASSWORD", "WEBHOOK", "URL") SEED_PROFILES = { "validated-core": { "_note": "The year-validated population config (2026-07-09 state).", "TRADING_MIN_QUALITY_SCORE": "0.60", "TRADING_MAX_FRESH_ENTRIES_PER_DAY": "10", "TRADING_MIN_ENTRY_GAP_PCT": "0.10", "TRADING_MIN_DAY_VOLUME": "300000", "TRADING_PRICE_MIN": "2.0", "TRADING_PRICE_MAX": "20.0", "TRIGGER_GAP_MIN": "10.0", "TRIGGER_GAP_MAX": "35", "TRIGGER_GAP_SQUEEZE_MIN": "150", "TRIGGER_GAP_SQUEEZE_RVOL": "5.0", }, "conservative": { "_note": "Fewer, stricter entries: B-grade floor, half the daily cap, no squeeze lottery.", "TRADING_MIN_QUALITY_SCORE": "0.75", "TRADING_MAX_FRESH_ENTRIES_PER_DAY": "5", "TRADING_MIN_ENTRY_GAP_PCT": "0.10", "TRADING_MIN_DAY_VOLUME": "300000", "TRADING_PRICE_MIN": "2.0", "TRADING_PRICE_MAX": "20.0", "TRIGGER_GAP_MIN": "10.0", "TRIGGER_GAP_MAX": "35", "TRIGGER_GAP_SQUEEZE_MIN": "0", }, "squeeze-hunter": { "_note": "Experiment: lean into the >150% volume-verified squeeze band + looser quality.", "TRADING_MIN_QUALITY_SCORE": "0.50", "TRADING_MAX_FRESH_ENTRIES_PER_DAY": "10", "TRADING_MIN_ENTRY_GAP_PCT": "0.10", "TRADING_MIN_DAY_VOLUME": "300000", "TRADING_PRICE_MIN": "2.0", "TRADING_PRICE_MAX": "20.0", "TRIGGER_GAP_MIN": "10.0", "TRIGGER_GAP_MAX": "35", "TRIGGER_GAP_SQUEEZE_MIN": "150", "TRIGGER_GAP_SQUEEZE_RVOL": "3.0", }, } def _safe_overrides() -> dict: """Overrides for API responses with secret VALUES redacted — a /api/mode live switch writes ALPACA_LIVE_* into overrides.env; without this they'd echo back in every /api/status and /api/config response.""" return {k: ("***" if any(m in k for m in SECRET_MARKERS) else v) for k, v in env_overrides.read_overrides().items()} def _editable(key: str) -> bool: if key in EDITABLE_EXACT: return True return (key.startswith(EDITABLE_PREFIXES) and not any(m in key for m in SECRET_MARKERS)) # ---------- docker over the unix socket (stdlib only) ---------- class _SockConn(http.client.HTTPConnection): def __init__(self): super().__init__("localhost") def connect(self): s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(DOCKER_SOCK) self.sock = s def docker(method: str, path: str) -> tuple[int, object]: try: c = _SockConn() c.request(method, path) r = c.getresponse() body = r.read() return r.status, (json.loads(body) if body else None) except Exception as exc: # noqa: BLE001 return 0, {"error": str(exc)} def list_containers() -> list[dict]: st, data = docker("GET", "/containers/json?all=1") if st != 200 or not isinstance(data, list): return [] out = [] for c in data: name = (c.get("Names") or ["?"])[0].lstrip("/") if "momentum" not in name and "postgres" not in name: continue out.append({ "name": name, "state": c.get("State"), "status": c.get("Status"), "health": ("healthy" if "(healthy)" in (c.get("Status") or "") else "unhealthy" if "(unhealthy)" in (c.get("Status") or "") else None), "image": c.get("Image"), "id": c.get("Id", "")[:12], }) return sorted(out, key=lambda x: x["name"]) # ---------- health checks ---------- def checks() -> dict: out: dict = {} try: from research.multi_schema import open_research_db con = open_research_db("market") r = con.execute("SELECT max(timestamp) FROM events").fetchall()[0][0] age = (datetime.now() - r).total_seconds() if r else None out["db"] = {"ok": True, "newest_event_age_s": round(age) if age else None} except Exception as exc: # noqa: BLE001 out["db"] = {"ok": False, "error": str(exc)[:120]} try: from alpaca_paper.client import AlpacaPaperClient from alpaca_paper.settings import AlpacaPaperSettings c = AlpacaPaperClient(AlpacaPaperSettings.from_env()) clock = c.get_clock() acct = c.get_account() out["alpaca"] = {"ok": True, "market_open": clock.get("is_open"), "equity": acct.get("equity"), "mode": ("LIVE" if "paper" not in os.environ.get("ALPACA_BASE_URL", "paper") else "paper")} except Exception as exc: # noqa: BLE001 out["alpaca"] = {"ok": False, "error": str(exc)[:120]} try: host = os.environ.get("OLLAMA_HOST", "http://192.168.1.9:11434") with urllib.request.urlopen(host + "/api/tags", timeout=4) as r: out["ollama"] = {"ok": r.status == 200} except Exception: # noqa: BLE001 out["ollama"] = {"ok": False, "note": "enrichment fails safe; trading unaffected"} out["feed"] = os.environ.get("ALPACA_DATA_FEED", "iex") return out # ---------- profiles ---------- def seed_profiles() -> None: try: os.makedirs(PROFILES_DIR, exist_ok=True) for name, body in SEED_PROFILES.items(): p = os.path.join(PROFILES_DIR, f"{name}.json") if not os.path.exists(p): json.dump(body, open(p, "w"), indent=2) except PermissionError: # host volume not writable by the container user (uid 10001) — degrade # instead of crash-looping; profiles/config editing will 500 until the # host dir is chown'd (see deploy/docker/README.md) print(f"[admin] WARNING: {PROFILES_DIR} not writable (chown -R 10001:10001 " "the data dir on the host); profiles/config editing disabled") def profiles() -> dict: seed_profiles() out = {} for f in sorted(os.listdir(PROFILES_DIR)): if f.endswith(".json"): try: out[f[:-5]] = json.load(open(os.path.join(PROFILES_DIR, f))) except Exception: # noqa: BLE001 pass return out # ---------- HTTP ---------- class Handler(BaseHTTPRequestHandler): def _send(self, code: int, body, ctype="application/json"): raw = body if isinstance(body, bytes) else json.dumps(body, default=str).encode() self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(raw))) self.end_headers() self.wfile.write(raw) def _authed(self) -> bool: # FAIL CLOSED: no token configured => deny (this panel fronts the docker # socket + the paper/live switch; an unset env var must not open it to the # LAN). Header only — a ?token= query would leak into proxy/access logs. if not ADMIN_TOKEN: return False return hmac.compare_digest(self.headers.get("X-Admin-Token", ""), ADMIN_TOKEN) def do_GET(self): # noqa: N802 path = self.path.split("?")[0] if path == "/": page = os.path.join(os.path.dirname(__file__), "api", "static", "admin.html") return self._send(200, open(page, "rb").read(), "text/html") if not self._authed(): return self._send(401, {"error": "bad or missing X-Admin-Token"}) if path == "/api/status": return self._send(200, {"containers": list_containers(), "checks": checks(), "overrides": _safe_overrides()}) if path == "/api/config": eff = {k: v for k, v in sorted(os.environ.items()) if _editable(k)} secrets = {k: bool(os.environ.get(k)) for k in ("ALPACA_API_KEY", "ALPACA_SECRET_KEY", "ALPACA_LIVE_API_KEY", "ALPACA_LIVE_SECRET_KEY", "TELEGRAM_BOT_TOKEN", "YOUTUBE_API_KEY", "FMP_API_KEY")} return self._send(200, {"effective": eff, "secrets_present": secrets, "overrides": _safe_overrides()}) if path == "/api/profiles": return self._send(200, profiles()) return self._send(404, {"error": "unknown route"}) def do_POST(self): # noqa: N802 if not self._authed(): return self._send(401, {"error": "bad or missing X-Admin-Token"}) n = int(self.headers.get("Content-Length") or 0) try: body = json.loads(self.rfile.read(n) or b"{}") except Exception: # noqa: BLE001 return self._send(400, {"error": "bad json"}) path = self.path.split("?")[0] if path == "/api/config": updates = body.get("set") or {} deletes = body.get("delete") or [] bad = [k for k in list(updates) + list(deletes) if not _editable(k)] if bad: return self._send(400, {"error": f"not editable: {bad}"}) merged = env_overrides.write_overrides( {**{k: str(v) for k, v in updates.items()}, **{k: None for k in deletes}}) return self._send(200, {"ok": True, "overrides": merged, "note": "restart affected services to apply"}) if path == "/api/profiles/apply": name = body.get("name", "") prof = profiles().get(name) if not prof: return self._send(404, {"error": f"no profile {name!r}"}) knobs = {k: str(v) for k, v in prof.items() if not k.startswith("_")} merged = env_overrides.write_overrides(knobs) return self._send(200, {"ok": True, "applied": name, "overrides": merged, "note": "restart the loop to apply"}) if path == "/api/restart": name = body.get("service", "") target = next((c for c in list_containers() if c["name"] == name), None) if not target: return self._send(404, {"error": f"no container {name!r}"}) st, _ = docker("POST", f"/containers/{target['id']}/restart?t=10") return self._send(200 if st in (200, 204) else 502, {"ok": st in (200, 204), "docker_status": st}) if path == "/api/mode": # paper/live switch — guarded: live requires ALPACA_LIVE_* keys present mode = body.get("mode") if mode == "live": if not (os.environ.get("ALPACA_LIVE_API_KEY") and os.environ.get("ALPACA_LIVE_SECRET_KEY")): return self._send(400, {"error": "ALPACA_LIVE_API_KEY/SECRET not set — " "refusing to switch to live"}) merged = env_overrides.write_overrides({ "ALPACA_BASE_URL": "https://api.alpaca.markets", "ALPACA_API_KEY": os.environ["ALPACA_LIVE_API_KEY"], "ALPACA_SECRET_KEY": os.environ["ALPACA_LIVE_SECRET_KEY"]}) elif mode == "paper": merged = env_overrides.write_overrides({ "ALPACA_BASE_URL": None, "ALPACA_API_KEY": None, "ALPACA_SECRET_KEY": None}) else: return self._send(400, {"error": "mode must be paper|live"}) return self._send(200, {"ok": True, "mode": mode, "overrides": merged, "note": "restart the loop to apply"}) return self._send(404, {"error": "unknown route"}) def log_message(self, *a): # quiet pass def main() -> None: host = os.environ.get("ADMIN_HOST", "0.0.0.0") port = int(os.environ.get("ADMIN_PORT", "8767")) seed_profiles() print(f"[admin] serving http://{host}:{port} " f"(token {'SET' if ADMIN_TOKEN else 'NOT SET — open access'}; " f"docker sock {'ok' if os.path.exists(DOCKER_SOCK) else 'MISSING'})") ThreadingHTTPServer((host, port), Handler).serve_forever() if __name__ == "__main__": main()