Admin panel (:8767): config, profiles, service control, paper/live switch
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7a91168b1
commit
f0fb91a069
9 files changed
+513
No files matched your search
+295
@@ -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()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Momentum Admin</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f6f7fb;--card:#fff;--ink:#1a2233;--mut:#68738a;--line:#e5e8f0;
|
||||||
|
--ok:#16a34a;--warn:#d97706;--bad:#dc2626;--acc:#2563eb;--accbg:#eff4ff;--r:14px}
|
||||||
|
@media (prefers-color-scheme:dark){:root{--bg:#0f1420;--card:#171e2e;--ink:#e8ecf6;
|
||||||
|
--mut:#8b96ad;--line:#232c42;--accbg:#1a2540}}
|
||||||
|
*{box-sizing:border-box;margin:0}
|
||||||
|
body{font:15px/1.5 -apple-system,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--ink);padding:24px}
|
||||||
|
.wrap{max-width:1100px;margin:0 auto}
|
||||||
|
header{display:flex;align-items:center;gap:14px;margin-bottom:22px;flex-wrap:wrap}
|
||||||
|
h1{font-size:20px;font-weight:700}
|
||||||
|
.badge{padding:3px 12px;border-radius:99px;font-size:12px;font-weight:700;letter-spacing:.4px}
|
||||||
|
.paper{background:#e0edff;color:#1d4ed8}.live{background:#fee2e2;color:#b91c1c}
|
||||||
|
.spacer{flex:1}
|
||||||
|
input[type=password],input[type=text]{background:var(--card);border:1px solid var(--line);
|
||||||
|
border-radius:8px;padding:7px 10px;color:var(--ink);font-size:14px}
|
||||||
|
h2{font-size:13px;text-transform:uppercase;letter-spacing:.8px;color:var(--mut);margin:26px 0 10px}
|
||||||
|
.grid{display:grid;gap:12px}.g3{grid-template-columns:repeat(auto-fill,minmax(240px,1fr))}
|
||||||
|
.g4{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px 16px}
|
||||||
|
.row{display:flex;align-items:center;gap:10px}
|
||||||
|
.dot{width:9px;height:9px;border-radius:99px;flex:none}
|
||||||
|
.ok{background:var(--ok)}.warn{background:var(--warn)}.bad{background:var(--bad)}
|
||||||
|
.mut{color:var(--mut);font-size:13px}
|
||||||
|
button{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:7px 14px;
|
||||||
|
font-size:13px;font-weight:600;cursor:pointer}
|
||||||
|
button.sec{background:var(--accbg);color:var(--acc)}
|
||||||
|
button.danger{background:var(--bad)}
|
||||||
|
button:disabled{opacity:.45;cursor:default}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||||
|
td,th{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left}
|
||||||
|
th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
td input{width:130px}
|
||||||
|
.tag{font-size:11px;background:var(--accbg);color:var(--acc);border-radius:6px;padding:1px 7px;font-weight:700}
|
||||||
|
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--ink);
|
||||||
|
color:var(--bg);padding:10px 20px;border-radius:10px;font-size:14px;opacity:0;transition:.3s}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><div class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1>Momentum Admin</h1>
|
||||||
|
<span id="mode" class="badge paper">PAPER</span>
|
||||||
|
<span id="mkt" class="mut"></span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<input type="password" id="token" placeholder="admin token" size="14">
|
||||||
|
<button class="sec" onclick="saveToken()">Connect</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2>Health</h2>
|
||||||
|
<div class="grid g4" id="health"></div>
|
||||||
|
|
||||||
|
<h2>Services</h2>
|
||||||
|
<div class="grid g3" id="services"></div>
|
||||||
|
|
||||||
|
<h2>Strategy profiles</h2>
|
||||||
|
<div class="grid g3" id="profiles"></div>
|
||||||
|
|
||||||
|
<h2>Configuration <span class="mut" style="text-transform:none;letter-spacing:0">— edits go to overrides.env; restart the loop to apply. <span class="tag">override</span> = differs from deployed env</span></h2>
|
||||||
|
<div class="card"><table id="cfg"><thead><tr><th>key</th><th>value</th><th></th></tr></thead><tbody></tbody></table>
|
||||||
|
<div class="row" style="margin-top:12px"><button onclick="saveCfg()">Save changes</button>
|
||||||
|
<span class="mut" id="cfgnote"></span></div></div>
|
||||||
|
|
||||||
|
<h2>Trading mode</h2>
|
||||||
|
<div class="card row">
|
||||||
|
<div style="flex:1"><b>Paper ⇄ Live switch</b><div class="mut">Live requires ALPACA_LIVE_API_KEY / ALPACA_LIVE_SECRET_KEY in the deployment env. Switching restarts nothing by itself — restart the loop after.</div></div>
|
||||||
|
<button class="sec" onclick="setMode('paper')">Paper</button>
|
||||||
|
<button class="danger" onclick="if(confirm('Switch the loop to LIVE trading with real money on next restart. Are you sure?'))setMode('live')">Go Live</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Secrets present</h2>
|
||||||
|
<div class="card" id="secrets"></div>
|
||||||
|
<div id="toast"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $=q=>document.querySelector(q);let staged={};
|
||||||
|
const tok=()=>localStorage.getItem('admintok')||'';
|
||||||
|
function saveToken(){localStorage.setItem('admintok',$('#token').value);load()}
|
||||||
|
async function api(p,opt={}){opt.headers={...(opt.headers||{}),'X-Admin-Token':tok(),'Content-Type':'application/json'};
|
||||||
|
const r=await fetch(p,opt);if(r.status===401){toast('bad token');throw 0}return r.json()}
|
||||||
|
function toast(m){const t=$('#toast');t.textContent=m;t.style.opacity=1;setTimeout(()=>t.style.opacity=0,2600)}
|
||||||
|
function dot(ok){return `<span class="dot ${ok===true?'ok':ok===false?'bad':'warn'}"></span>`}
|
||||||
|
async function load(){
|
||||||
|
const s=await api('/api/status');
|
||||||
|
const c=s.checks||{};
|
||||||
|
$('#health').innerHTML=[
|
||||||
|
`<div class="card row">${dot(c.db?.ok)}<div><b>Database</b><div class="mut">${c.db?.ok?`event ${c.db.newest_event_age_s}s ago`:c.db?.error||''}</div></div></div>`,
|
||||||
|
`<div class="card row">${dot(c.alpaca?.ok)}<div><b>Alpaca</b><div class="mut">${c.alpaca?.ok?`$${(+c.alpaca.equity).toLocaleString()} · market ${c.alpaca.market_open?'open':'closed'}`:c.alpaca?.error||''}</div></div></div>`,
|
||||||
|
`<div class="card row">${dot(c.ollama?.ok)}<div><b>Ollama</b><div class="mut">${c.ollama?.ok?'enrichment up':'down (fails safe)'}</div></div></div>`,
|
||||||
|
`<div class="card row">${dot(c.feed==='sip')}<div><b>Data feed</b><div class="mut">${c.feed}</div></div></div>`].join('');
|
||||||
|
$('#mode').className='badge '+(c.alpaca?.mode==='LIVE'?'live':'paper');
|
||||||
|
$('#mode').textContent=c.alpaca?.mode==='LIVE'?'LIVE':'PAPER';
|
||||||
|
$('#mkt').textContent=c.alpaca?.market_open?'market open':'market closed';
|
||||||
|
$('#services').innerHTML=(s.containers||[]).map(x=>`<div class="card row">
|
||||||
|
${dot(x.health?x.health==='healthy':x.state==='running')}
|
||||||
|
<div style="flex:1"><b>${x.name.replace(/^ix-|-1$/g,'')}</b><div class="mut">${x.status||x.state}</div></div>
|
||||||
|
<button class="sec" onclick="restart('${x.name}')">restart</button></div>`).join('');
|
||||||
|
const cfg=await api('/api/config');staged={};
|
||||||
|
$('#cfg tbody').innerHTML=Object.entries(cfg.effective).map(([k,v])=>`<tr>
|
||||||
|
<td>${k} ${cfg.overrides[k]!==undefined?'<span class="tag">override</span>':''}</td>
|
||||||
|
<td><input type="text" value="${v}" oninput="staged['${k}']=this.value"></td>
|
||||||
|
<td>${cfg.overrides[k]!==undefined?`<button class="sec" onclick="delKey('${k}')">reset</button>`:''}</td></tr>`).join('');
|
||||||
|
$('#secrets').innerHTML=Object.entries(cfg.secrets_present).map(([k,v])=>
|
||||||
|
`<span style="margin-right:18px">${v?'✅':'❌'} ${k}</span>`).join('');
|
||||||
|
const prof=await api('/api/profiles');
|
||||||
|
$('#profiles').innerHTML=Object.entries(prof).map(([n,p])=>`<div class="card">
|
||||||
|
<b>${n}</b><div class="mut" style="min-height:40px">${p._note||''}</div>
|
||||||
|
<button onclick="applyProfile('${n}')">Apply</button></div>`).join('');
|
||||||
|
}
|
||||||
|
async function saveCfg(){if(!Object.keys(staged).length)return toast('nothing changed');
|
||||||
|
await api('/api/config',{method:'POST',body:JSON.stringify({set:staged})});
|
||||||
|
toast('saved — restart the loop to apply');load()}
|
||||||
|
async function delKey(k){await api('/api/config',{method:'POST',body:JSON.stringify({delete:[k]})});load()}
|
||||||
|
async function applyProfile(n){await api('/api/profiles/apply',{method:'POST',body:JSON.stringify({name:n})});
|
||||||
|
toast(`profile ${n} staged — restart the loop to apply`);load()}
|
||||||
|
async function restart(n){toast('restarting '+n+'…');
|
||||||
|
const r=await api('/api/restart',{method:'POST',body:JSON.stringify({service:n})});
|
||||||
|
toast(r.ok?'restarted '+n:'restart failed');setTimeout(load,3000)}
|
||||||
|
async function setMode(m){const r=await api('/api/mode',{method:'POST',body:JSON.stringify({mode:m})});
|
||||||
|
toast(r.error||('mode -> '+m+' — restart the loop to apply'));load()}
|
||||||
|
$('#token').value=tok();load().catch(()=>{});setInterval(()=>load().catch(()=>{}),15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,6 +19,8 @@ from dotenv import load_dotenv
|
|||||||
# 192.168.1.5:5432) — the event store reads it. Without this the standalone
|
# 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.
|
# dashboard fell back to the legacy local DuckDB path and showed nothing.
|
||||||
load_dotenv()
|
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
|
from api.main import DashboardState, create_server # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ services:
|
|||||||
<<: *app
|
<<: *app
|
||||||
command: ["python", "-u", "-m", "youtube_claims.run", "run"]
|
command: ["python", "-u", "-m", "youtube_claims.run", "run"]
|
||||||
|
|
||||||
|
admin:
|
||||||
|
<<: *app
|
||||||
|
command: ["python", "-u", "admin_api.py"]
|
||||||
|
environment:
|
||||||
|
<<: *app-env
|
||||||
|
ADMIN_PORT: "8767"
|
||||||
|
ADMIN_TOKEN: <FILL-ME-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:
|
scheduler:
|
||||||
<<: *app
|
<<: *app
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *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:
|
scheduler:
|
||||||
# Weekday-evening premarket eval + DB/log housekeeping at 20:00 ET —
|
# Weekday-evening premarket eval + DB/log housekeeping at 20:00 ET —
|
||||||
# replaces the momentum-premarket-eval systemd timer.
|
# replaces the momentum-premarket-eval systemd timer.
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
def build_runtime(args: argparse.Namespace) -> dict:
|
def build_runtime(args: argparse.Namespace) -> dict:
|
||||||
"""Wire up every component; safe without API keys."""
|
"""Wire up every component; safe without API keys."""
|
||||||
load_dotenv()
|
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()]
|
explicit_symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -235,6 +235,8 @@ def main() -> None:
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
|
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")
|
token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||||
allowed = os.environ.get("TELEGRAM_CHAT_ID")
|
allowed = os.environ.get("TELEGRAM_CHAT_ID")
|
||||||
if not token or not allowed:
|
if not token or not allowed:
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ FFMPEG_LOCATION = os.getenv("YT_FFMPEG_LOCATION") or (os.path.dirname(_ff) if _f
|
|||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv(os.path.join(_ROOT, ".env"))
|
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
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user