Files
momentum-system/admin_api.py
T
momentum devandClaude Opus 4.8 d54a2a930c Review sweep: money-path fixes + security + delete 805MB dead weight
Consolidated 4-tool review (2 correctness agents + Codex + over-eng audit).

Money-path (regression-tested):
- _flatten_all: moved the fresh-broker close from close_session INTO the
  shared function + added a broker-held guard on entry cancellation, so
  the daily-loss CIRCUIT BREAKER (which called _flatten_all raw) no
  longer strips a just-filled bracket's legs or leaves a sync-gap fill
  naked. One guard, all callers.
- restart state rebuild: _fresh_entries cap, _requested_symbols dedup,
  and losing benches now rebuild from today's events on boot — a
  mid-session restart no longer resets the cap to 0 or re-fires a
  resting order into a duplicate (the 2026-07-06 incident).
- POST /orders no longer retries a lost-response URLError (was
  re-sending duplicate live orders); GET/DELETE still retry.
- expire_stale_entries now @_locked (raced the trigger thread).
- _session_open: ET conversion instead of a hardcoded 13:30-13:33Z
  window that only matched EDT — the fast path armed nothing all winter.

Security:
- admin panel fail-CLOSED on empty ADMIN_TOKEN (was fail-open on a panel
  fronting the docker socket); header-only token, constant-time compare.
- admin API responses redact secret override VALUES (live keys would
  have echoed after a paper->live switch).
- dashboard: removed wildcard CORS (cross-origin drive-by could drive
  trade actions); optional DASHBOARD_TOKEN gate on POST.

Deletions (~805MB + dead code shipped in the prod image): momentum-
rebuilt.zip, archive venv/htmlcov, and schwab/ core/ telemetry/ models/
runtime/providers/ storage/replay.py controlroom/ + three dead
evaluation stubs + package.json — all verified zero-importer.

+4 regression tests. 99 money-path/trigger/exit tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:00:10 -04:00

314 lines
14 KiB
Python

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