From f0fb91a069be3ab3d237fc13dabace1987661c59 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Thu, 9 Jul 2026 18:37:18 -0400 Subject: [PATCH] Admin panel (:8767): config, profiles, service control, paper/live switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New admin service — LAN-only, token-authed (ADMIN_TOKEN), card-based UI matching the owner's preferred dashboard style. Capabilities: - health: docker containers via /var/run/docker.sock (stdlib unix-socket HTTP client), DB heartbeat, Alpaca account/clock, Ollama, feed - config: whitelisted strategy knobs (TRADING_/TRIGGER_/WATCHER_/...) edited via data/overrides.env, which every entrypoint now loads with override=True at boot (runtime/env_overrides.py) — so changes apply on container restart without touching the deployment YAML; secrets are presence-only and rejected server-side if edited - profiles: named knob presets (seeded: validated-core, conservative, squeeze-hunter) applied in one click - restart buttons per container; paper/live switch guarded behind ALPACA_LIVE_* keys existing Verified live in docker: auth 401s, status/config/profiles/write paths, secret-edit rejection, page serves. Added to both compose files. Co-Authored-By: Claude Opus 4.8 --- admin_api.py | 295 ++++++++++++++++++++++++++++++ api/static/admin.html | 128 +++++++++++++ dashboard_api.py | 2 + deploy/docker/truenas-compose.yml | 13 ++ docker-compose.yml | 14 ++ run_live_paper.py | 2 + runtime/env_overrides.py | 52 ++++++ telegram_bot.py | 2 + youtube_claims/config.py | 5 + 9 files changed, 513 insertions(+) create mode 100644 admin_api.py create mode 100644 api/static/admin.html create mode 100644 runtime/env_overrides.py diff --git a/admin_api.py b/admin_api.py new file mode 100644 index 0000000..056ee18 --- /dev/null +++ b/admin_api.py @@ -0,0 +1,295 @@ +"""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 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 _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: + 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) + + +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: + if not ADMIN_TOKEN: + return True # explicit opt-out: no token configured + return (self.headers.get("X-Admin-Token") == ADMIN_TOKEN + or f"token={ADMIN_TOKEN}" in (self.path.split("?", 1) + [""])[1]) + + 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": env_overrides.read_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": env_overrides.read_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() diff --git a/api/static/admin.html b/api/static/admin.html new file mode 100644 index 0000000..49a794f --- /dev/null +++ b/api/static/admin.html @@ -0,0 +1,128 @@ + + + + + +Momentum Admin + + +
+
+

Momentum Admin

+ PAPER + +
+ + +
+ +

Health

+
+ +

Services

+
+ +

Strategy profiles

+
+ +

Configuration — edits go to overrides.env; restart the loop to apply. override = differs from deployed env

+
keyvalue
+
+
+ +

Trading mode

+
+
Paper ⇄ Live switch
Live requires ALPACA_LIVE_API_KEY / ALPACA_LIVE_SECRET_KEY in the deployment env. Switching restarts nothing by itself — restart the loop after.
+ + +
+ +

Secrets present

+
+
+
+ + + diff --git a/dashboard_api.py b/dashboard_api.py index 33aa597..9a0da30 100644 --- a/dashboard_api.py +++ b/dashboard_api.py @@ -19,6 +19,8 @@ from dotenv import load_dotenv # 192.168.1.5:5432) — the event store reads it. Without this the standalone # dashboard fell back to the legacy local DuckDB path and showed nothing. load_dotenv() +from runtime.env_overrides import load as _load_overrides +_load_overrides() # admin-panel overrides beat compose env from api.main import DashboardState, create_server # noqa: E402 diff --git a/deploy/docker/truenas-compose.yml b/deploy/docker/truenas-compose.yml index 1cbb42d..47ebe47 100644 --- a/deploy/docker/truenas-compose.yml +++ b/deploy/docker/truenas-compose.yml @@ -102,6 +102,19 @@ services: <<: *app command: ["python", "-u", "-m", "youtube_claims.run", "run"] + admin: + <<: *app + command: ["python", "-u", "admin_api.py"] + environment: + <<: *app-env + ADMIN_PORT: "8767" + ADMIN_TOKEN: + ports: + - "8767:8767" + volumes: + - /mnt/volume/Apps/momentum/data:/app/data + - /var/run/docker.sock:/var/run/docker.sock # service status + restarts + scheduler: <<: *app command: diff --git a/docker-compose.yml b/docker-compose.yml index f5101f6..b14a02d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,20 @@ services: environment: <<: *app-env + admin: + <<: *app + container_name: momentum-admin + command: ["python", "-u", "admin_api.py"] + environment: + <<: *app-env + ADMIN_PORT: "8767" + # set ADMIN_TOKEN in .env — without it the panel is open on the LAN + ports: + - "8767:8767" + volumes: + - app-data:/app/data + - /var/run/docker.sock:/var/run/docker.sock # service status + restarts + scheduler: # Weekday-evening premarket eval + DB/log housekeeping at 20:00 ET — # replaces the momentum-premarket-eval systemd timer. diff --git a/run_live_paper.py b/run_live_paper.py index a3f49a8..674d771 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -139,6 +139,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def build_runtime(args: argparse.Namespace) -> dict: """Wire up every component; safe without API keys.""" load_dotenv() + from runtime.env_overrides import load as _load_overrides + _load_overrides() # admin-panel overrides beat compose env explicit_symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()] diff --git a/runtime/env_overrides.py b/runtime/env_overrides.py new file mode 100644 index 0000000..8e920a9 --- /dev/null +++ b/runtime/env_overrides.py @@ -0,0 +1,52 @@ +"""Runtime env overrides: /app/data/overrides.env (shared volume) beats the +compose-baked environment. The admin panel writes this file and restarts the +service; every entrypoint calls load() first so changes take effect on boot +without touching the deployment YAML.""" + +from __future__ import annotations + +import os + +OVERRIDES_FILE = os.environ.get("ENV_OVERRIDES_FILE") or os.path.join( + os.path.dirname(__file__), "..", "data", "overrides.env") + + +def load() -> int: + """Apply overrides (override=True). Returns count of applied keys.""" + try: + from dotenv import load_dotenv + if os.path.exists(OVERRIDES_FILE): + before = dict(os.environ) + load_dotenv(OVERRIDES_FILE, override=True) + return sum(1 for k, v in os.environ.items() if before.get(k) != v) + except Exception: # noqa: BLE001 — a broken overrides file must not stop boot + pass + return 0 + + +def read_overrides() -> dict[str, str]: + out: dict[str, str] = {} + if not os.path.exists(OVERRIDES_FILE): + return out + for line in open(OVERRIDES_FILE): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, _, v = line.partition("=") + out[k.strip()] = v.strip() + return out + + +def write_overrides(updates: dict[str, str | None]) -> dict[str, str]: + """Merge updates into the overrides file (None deletes a key).""" + cur = read_overrides() + for k, v in updates.items(): + if v is None: + cur.pop(k, None) + else: + cur[k] = str(v) + os.makedirs(os.path.dirname(os.path.abspath(OVERRIDES_FILE)), exist_ok=True) + with open(OVERRIDES_FILE, "w") as f: + f.write("# Written by the momentum admin panel — beats compose env at boot.\n") + for k in sorted(cur): + f.write(f"{k}={cur[k]}\n") + return cur diff --git a/telegram_bot.py b/telegram_bot.py index 4cf6162..345b6e4 100644 --- a/telegram_bot.py +++ b/telegram_bot.py @@ -235,6 +235,8 @@ def main() -> None: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") from dotenv import load_dotenv load_dotenv(os.path.join(os.path.dirname(__file__), ".env")) + from runtime.env_overrides import load as _load_overrides + _load_overrides() token = os.environ.get("TELEGRAM_BOT_TOKEN") allowed = os.environ.get("TELEGRAM_CHAT_ID") if not token or not allowed: diff --git a/youtube_claims/config.py b/youtube_claims/config.py index 734b431..656d703 100644 --- a/youtube_claims/config.py +++ b/youtube_claims/config.py @@ -15,6 +15,11 @@ FFMPEG_LOCATION = os.getenv("YT_FFMPEG_LOCATION") or (os.path.dirname(_ff) if _f try: from dotenv import load_dotenv load_dotenv(os.path.join(_ROOT, ".env")) + try: + from runtime.env_overrides import load as _load_overrides + _load_overrides() + except Exception: + pass except Exception: # noqa: BLE001 pass