"""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 subprocess import sys 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") # Regime backtest (plan 011) on-demand trigger — logs to the app-data volume so # GET /api/regime/status can tail it (the panel had no way to fire this). REGIME_LOG = os.environ.get( "REGIME_RUN_LOG", os.path.join(os.path.dirname(__file__), "data", "regime_run.log")) _REGIME_DONE = "REGIME PIPELINE DONE" def run_regime_pipeline() -> int: """Fire the full regime shadow pipeline DETACHED (it takes a few min): rebuild session labels -> walk-forward score both models -> replay + adjudicate the regime_gate experiments. Returns the child pid. Shadow only — never touches a live order. stdout+stderr -> REGIME_LOG (truncated each run so status shows the CURRENT run); a trailing marker lets status report completion.""" py = sys.executable steps = " && ".join([ f"{py} -m research.regime build-features", f"{py} -m research.regime score --model threshold_v1", f"{py} -m research.regime score --model meta_v1", f"{py} -m research.experiments run", f"{py} -m research.experiments adjudicate", f"echo {_REGIME_DONE}", ]) os.makedirs(os.path.dirname(REGIME_LOG), exist_ok=True) log = open(REGIME_LOG, "wb") # noqa: SIM115 — handed to the detached child proc = subprocess.Popen(["/bin/sh", "-c", steps], stdout=log, stderr=subprocess.STDOUT, start_new_session=True) return proc.pid def regime_status() -> dict: """Tail REGIME_LOG + whether the last run finished (the marker) — no DB query needed; the adjudicate step prints each experiment's verdict into the log.""" try: with open(REGIME_LOG, encoding="utf-8", errors="replace") as fh: lines = fh.read().splitlines() except FileNotFoundError: return {"log_tail": [], "running": False, "ran": False} return {"log_tail": lines[-40:], "ran": True, "running": _REGIME_DONE not in lines[-3:] if lines else False} 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)) def _mask_value(key: str, value: str) -> str: """Mask secret VALUES (*** + last 4). Uses the broader SECRET_MARKERS set so DATABASE_URL / *_WEBHOOK_URL are masked too — narrowing to KEY|SECRET|TOKEN| PASSWORD would leak the DB password in the config table.""" if any(m in key.upper() for m in SECRET_MARKERS): tail = value[-4:] if value and len(value) >= 4 else "" return "***" + tail return value def _env_example_keys() -> set[str]: """Keys documented in .env.example — the 'required' config surface used for drift detection (a documented key absent from the effective env is drift).""" path = os.path.join(os.path.dirname(__file__), ".env.example") keys: set[str] = set() try: with open(path) as fh: for line in fh: line = line.strip() if line and not line.startswith("#") and "=" in line: keys.add(line.split("=", 1)[0].strip()) except Exception: # noqa: BLE001 pass return keys def effective_config() -> dict: """Per-key effective value + source for the admin config table. Merge order baked < compose < overrides.env (< learned). Sources we can prove at runtime: ``overrides.env`` (from data/overrides.env) vs ``compose`` (the process env — baked and compose are indistinguishable inside the container, so both surface as ``compose``); a documented .env.example key that is absent surfaces as ``missing`` (drift). Secret values are masked.""" overrides = env_overrides.read_overrides() example = _env_example_keys() keys = set(example) | set(overrides) | {k for k in os.environ if _editable(k)} out: dict[str, dict] = {} for key in sorted(keys): value = os.environ.get(key) if value is None: out[key] = {"value_masked": None, "source": "missing" if key in example else "unset", "drift": key in example} continue source = "overrides.env" if key in overrides else "compose" out[key] = {"value_masked": _mask_value(key, str(value)), "source": source} return out # ---------- 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()) if path == "/api/config/effective": return self._send(200, {"config": effective_config()}) if path == "/api/regime/status": return self._send(200, regime_status()) 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"}) if path == "/api/regime/run": try: pid = run_regime_pipeline() except Exception as e: # noqa: BLE001 return self._send(500, {"ok": False, "error": str(e)[:200]}) return self._send(200, {"ok": True, "pid": pid, "log": REGIME_LOG, "note": "regime shadow pipeline started (~a few min); " "poll GET /api/regime/status"}) 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()