From 4e2db680494844d0d596d60daa4fda1f35af713f Mon Sep 17 00:00:00 2001 From: Brent Neale Date: Thu, 19 Feb 2026 15:53:54 +1000 Subject: [PATCH] Add Railway deployment config for 24/7 cloud paper trading - Dockerfile: add templates copy, ENV PORT, update CMD to live engine - Create src/live/health.py: threaded HTTP health server (/health, /state) - Wire health server into src/live/run.py before engine loop - Dashboard: add unauthenticated /health endpoint, use PORT env var - Create railway.toml with Dockerfile builder and health check config - docker-compose.yml: rename service to live-engine, add PORT env vars Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 4 +++- docker-compose.yml | 10 +++++++-- railway.toml | 9 ++++++++ src/dashboard.py | 7 +++++- src/live/health.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ src/live/run.py | 3 +++ 6 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 railway.toml create mode 100644 src/live/health.py diff --git a/Dockerfile b/Dockerfile index 2e5074c..eda6645 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,10 @@ RUN pip install --no-cache-dir -r requirements.docker.txt COPY config/system.yaml config/system.yaml COPY src/ src/ COPY sql/ sql/ +COPY templates/ templates/ RUN mkdir -p logs models ENV PYTHONUNBUFFERED=1 -CMD ["python", "src/order_executor.py"] +ENV PORT=8080 +CMD ["python", "src/live/run.py"] diff --git a/docker-compose.yml b/docker-compose.yml index b9211f8..760e3ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,17 @@ services: - fx-quant: + live-engine: build: . - container_name: fx-quant + container_name: fx-quant-engine env_file: - config/.env + environment: + - PORT=8080 volumes: - ./config:/app/config - ./logs:/app/logs - ./models:/app/models + ports: + - "8080:8080" restart: unless-stopped dashboard: @@ -16,6 +20,8 @@ services: command: python src/dashboard.py env_file: - config/.env + environment: + - PORT=5000 volumes: - ./config:/app/config - ./logs:/app/logs:ro diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..2eb66d9 --- /dev/null +++ b/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "dockerfile" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 5 diff --git a/src/dashboard.py b/src/dashboard.py index 53357a0..8bc0088 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -161,6 +161,11 @@ def validate_config(cfg): # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- +@app.route("/health") +def health(): + return jsonify({"status": "ok"}) + + @app.route("/") @requires_auth def index(): @@ -589,4 +594,4 @@ def _parse_int_list(s): # Main # --------------------------------------------------------------------------- if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000, debug=False) + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=False) diff --git a/src/live/health.py b/src/live/health.py new file mode 100644 index 0000000..a022add --- /dev/null +++ b/src/live/health.py @@ -0,0 +1,53 @@ +""" +Lightweight threaded HTTP health server for Railway liveness checks. + +Runs as a daemon thread so it never blocks the main trading engine loop. +""" +import json +import os +import time +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler + +_start_time = time.time() + +STATE_FILE = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "logs", "live_state.json", +) + + +class _HealthHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + body = json.dumps({ + "status": "ok", + "uptime_seconds": round(time.time() - _start_time, 1), + }) + self._respond(200, body) + elif self.path == "/state": + try: + with open(STATE_FILE) as f: + body = f.read() + except FileNotFoundError: + body = json.dumps({"error": "state file not found"}) + self._respond(200, body) + else: + self._respond(404, json.dumps({"error": "not found"})) + + def _respond(self, code: int, body: str): + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body.encode()) + + def log_message(self, format, *args): + pass # suppress request logs to keep engine output clean + + +def start_health_server(): + port = int(os.environ.get("PORT", 8080)) + server = HTTPServer(("0.0.0.0", port), _HealthHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(f"Health server listening on :{port}") diff --git a/src/live/run.py b/src/live/run.py index 58e2589..78cae17 100644 --- a/src/live/run.py +++ b/src/live/run.py @@ -89,6 +89,9 @@ def build_slots(config: dict) -> list[StrategySlot]: def main(): + from src.live.health import start_health_server + start_health_server() + config = load_config() if not config: print("ERROR: No phase2 config found in config/system.yaml")