diff --git a/.env.docker.tmp b/.env.docker.tmp new file mode 100644 index 0000000..5ed018e --- /dev/null +++ b/.env.docker.tmp @@ -0,0 +1,45 @@ +# ───────────────────────────────────────────────────────────────────────────── +# IB Gateway (gnzsnz/ib-gateway:stable) +# ───────────────────────────────────────────────────────────────────────────── +TWS_USERID=zemheritrader +TWS_PASSWORD=3bmp8P$$K4(>89oZ}xQ +TRADING_MODE=paper # paper | live +VNC_SERVER_PASSWORD=GucluVNC123 +TWOFA_TIMEOUT_ACTION=exit # restart | exit +AUTO_RESTART_TIME=11:59 PM +RELOGIN_AFTER_2FA_TIMEOUT=no +TIME_ZONE=Europe/Istanbul # e.g. America/New_York, Europe/London + +# ───────────────────────────────────────────────────────────────────────────── +# FastAPI Application +# ───────────────────────────────────────────────────────────────────────────── +IBKR_HOST=ib-gateway # Docker service name; use 127.0.0.1 for local +IBKR_PORT=4002 # paper=4002, live=4001 +IBKR_CLIENT_ID=1 +WEBHOOK_SECRET=change_this_to_a_strong_random_secret # openssl rand -hex 32 +DB_PATH=/app/trades.db + +# ───────────────────────────────────────────────────────────────────────────── +# Risk Management +# ───────────────────────────────────────────────────────────────────────────── +MAX_DAILY_LOSS=500.0 +MAX_POSITIONS=5 +MAX_ORDER_VALUE=10000.0 + +# ───────────────────────────────────────────────────────────────────────────── +# UI Authentication (HTTP Basic Auth — opt-in) +# Leave both unset to disable auth (trusted network / Tailscale only) +# Set both to enable auth on all UI pages (/, /scanner, /tradelog, /portfolio) +# /health and /webhook are always public regardless of this setting +# ───────────────────────────────────────────────────────────────────────────── +# UI_USERNAME=admin +# UI_PASSWORD=change_this_strong_password + +# ───────────────────────────────────────────────────────────────────────────── +# Traefik (external reverse proxy — must be running as a separate stack) +# ───────────────────────────────────────────────────────────────────────────── +TRAEFIK_NETWORK=traefik-public # External Docker network Traefik is attached to +TRAEFIK_HOST=ibkr.your-tailnet.ts.net # Hostname Traefik routes on (your Tailscale FQDN) +TRAEFIK_ENTRYPOINT=websecure # Traefik entrypoint name (websecure=HTTPS, web=HTTP) +RELOGIN_AFTER_TWOFA_TIMEOUT=no +EXISTING_SESSION_DETECTED_ACTION=primaryoverride diff --git a/.env.example b/.env.example index 6213988..7a88dfe 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +# For Docker Compose, prefer docker.env.example + ./secrets instead of this file. # ───────────────────────────────────────────────────────────────────────────── # IB Gateway (gnzsnz/ib-gateway:stable) # ───────────────────────────────────────────────────────────────────────────── @@ -7,17 +8,18 @@ TRADING_MODE=paper # paper | live VNC_SERVER_PASSWORD=your_vnc_password TWOFA_TIMEOUT_ACTION=restart # restart | exit AUTO_RESTART_TIME=11:59 PM -RELOGIN_AFTER_2FA_TIMEOUT=yes +RELOGIN_AFTER_TWOFA_TIMEOUT=yes +EXISTING_SESSION_DETECTED_ACTION=primaryoverride # primary | primaryoverride | secondary TIME_ZONE=Europe/Istanbul # e.g. America/New_York, Europe/London # ───────────────────────────────────────────────────────────────────────────── # FastAPI Application # ───────────────────────────────────────────────────────────────────────────── IBKR_HOST=ib-gateway # Docker service name; use 127.0.0.1 for local -IBKR_PORT=4002 # paper=4002, live=4001 +IBKR_PORT=4002 # local paper=4002, local live=4001; docker service uses 4004/4003 IBKR_CLIENT_ID=1 WEBHOOK_SECRET=change_this_to_a_strong_random_secret # openssl rand -hex 32 -DB_PATH=/app/trades.db +DB_PATH=/app/data/trades.db # ───────────────────────────────────────────────────────────────────────────── # Risk Management diff --git a/.gitignore b/.gitignore index ad5f42c..d33fd46 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ venv/ dist/ build/ .DS_Store +docker.env +secrets/* +!secrets/.gitkeep +!secrets/README.md diff --git a/OPERATIONS.md b/OPERATIONS.md new file mode 100644 index 0000000..8efaf97 --- /dev/null +++ b/OPERATIONS.md @@ -0,0 +1,174 @@ +# IBKR Dashboard Operasyon Rehberi + +Bu rehber, projeyi yerelde Docker ile baslatmak, saglik kontrolu yapmak, smoke test calistirmak ve guvenli sekilde kapatmak icin kisa bir operasyon akisi verir. + +## 1. Onkosullar + +- Docker Desktop calisiyor olmali. +- Docker icin repo kokunde `docker.env` dosyasi olmali. +- Lokal Python calismalari icin isterseniz ayri bir `.env` dosyasi tutabilirsiniz. +- `.env` icindeki `WEBHOOK_SECRET` varsayilan degerde birakilmamali. +- Docker secret dosyalari `./secrets` altinda olusturulmali. + +## 2. Hazirlik + +`docker.env.example` dosyasini `docker.env` olarak kopyalayin. Ardindan gerekli secret dosyalarini olusturun: + +```bash +cp docker.env.example docker.env +mkdir -p secrets +printf '%s\n' 'your_ibkr_password' > secrets/tws_password +printf '%s\n' 'change_this_webhook_secret' > secrets/webhook_secret + +chmod 600 secrets/tws_password secrets/webhook_secret + +docker network inspect traefik-public >/dev/null 2>&1 || docker network create traefik-public +``` + +Notlar: + +- `primaryoverride`, mevcut bir IBKR oturumu varsa gateway'in login dialogunda takilma riskini azaltir. +- Uygulama SQLite veritabani icin `./data` dizinini kullanir. Dizin repo icinde mevcut olmali. +- Opsiyonel secret dosyalari: `secrets/vnc_password`, `secrets/ui_username`, `secrets/ui_password`. + +## 3. Stack Baslatma + +Servisleri build edip arka planda kaldirin: + +```bash +docker compose --env-file docker.env up -d --build +``` + +Durumu kontrol edin: + +```bash +docker compose --env-file docker.env ps -a +``` + +Beklenen durum: + +- `ib-gateway`: `Up (healthy)` +- `ibkr-dashboard`: `Up` veya kisa sure sonra `Up (healthy)` + +Log takibi icin: + +```bash +docker compose --env-file docker.env logs -f ib-gateway ibkr-dashboard +``` + +## 4. Health Kontrolu + +Uygulama health endpoint'ini dogrulayin: + +```bash +curl -sS --max-time 10 http://localhost:8000/health +``` + +Beklenen cevap ornegi: + +```json +{"status":"ok","ib_connected":true,"account":"...","db_ok":true,"version":"3.0"} +``` + +`status=ok`, `ib_connected=true` ve `db_ok=true` gorulmelidir. + +## 5. Test ve Smoke Dogrulama + +Offline test paketi: + +```bash +pytest tests/ -q --tb=short +``` + +Beklenen sonuc: + +```text +47 passed, 2 skipped +``` + +Docker smoke testi: + +```bash +./verify_docker.sh +``` + +Guncel dogrulanmis durum: + +- Script 18/18 check geciyor. + +Canli IB entegrasyon testlerini ozellikle acmak isterseniz: + +```bash +RUN_LIVE_IB_TESTS=1 pytest tests/test_integration.py -q --tb=short +``` + +## 6. Sorun Giderme + +### Docker Compose `$` warning'i + +Semptom: + +```text +The "K4" variable is not set. Defaulting to a blank string. +``` + +Cozum: + +- Compose komutlarini `.env.docker.tmp` ile calistirin. +- Docker icin `docker compose --env-file docker.env ...` kullanin. +- Secret degerlerini `./secrets` altindaki dosyalardan okuyun. + +### Gateway login oluyor ama healthy olmuyor + +Kontrol edin: + +```bash +docker compose --env-file docker.env logs --tail=100 ib-gateway +``` + +Beklenen satirlar: + +- `Login has completed` +- `Configuration tasks completed` + +### Dashboard ayaga kalkmiyor + +Kontrol edin: + +```bash +docker compose --env-file docker.env logs --tail=100 ibkr-dashboard +``` + +Ozellikle su hatalara bakin: + +- `sqlite3.OperationalError: unable to open database file` +- `TimeoutError` veya IB gateway baglanti hatalari + +## 7. Kapatma + +Servisleri temiz kapatmak icin: + +```bash +docker compose --env-file docker.env down --remove-orphans +``` + +Docker secret dosyalarini yerinde birakip sadece `docker.env` kaldirilabilir: + +```bash +rm -f docker.env +``` + +## 8. Operasyon Notlari + +- `verify_docker.sh`, varsayilan olarak `docker.env` varsa onu kullanir; yoksa `.env`'e geri duser. +- Docker ic aginda dashboard, gateway'e `ib-gateway:4004` uzerinden baglanir. +- Host makinede paper API erisimi `127.0.0.1:4002`, live API erisimi `127.0.0.1:4001` olarak map edilir. + +## 9. Geriye Donuk Fallback + +Eger mevcut `.env` ile devam etmek zorundaysaniz, eski gecici dosya akisi hala kullanilabilir: + +```bash +sed 's/\$/\$\$/g' .env > .env.docker.tmp +COMPOSE_ENV_FILE=.env.docker.tmp COMPOSE_APP_ENV_FILE=.env.docker.tmp ./verify_docker.sh +``` \ No newline at end of file diff --git a/compose_config_stderr.log b/compose_config_stderr.log new file mode 100644 index 0000000..e69de29 diff --git a/config_utils.py b/config_utils.py new file mode 100644 index 0000000..c2ecea5 --- /dev/null +++ b/config_utils.py @@ -0,0 +1,15 @@ +from pathlib import Path +import os +from typing import Optional + + +def read_env_or_file(name: str, default: Optional[str] = None) -> Optional[str]: + file_path = os.getenv(f"{name}_FILE") + if file_path: + return Path(file_path).read_text(encoding="utf-8").strip() + + value = os.getenv(name) + if value is not None: + return value + + return default diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 9ad4d51..7e4938d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,21 +3,23 @@ services: ib-gateway: image: ghcr.io/gnzsnz/ib-gateway:stable restart: always + env_file: + - ${COMPOSE_APP_ENV_FILE:-docker.env} environment: - - TWS_USERID=${TWS_USERID} - - TWS_PASSWORD=${TWS_PASSWORD} - TRADING_MODE=${TRADING_MODE:-paper} - - VNC_SERVER_PASSWORD=${VNC_SERVER_PASSWORD} - TWOFA_TIMEOUT_ACTION=${TWOFA_TIMEOUT_ACTION:-restart} - AUTO_RESTART_TIME=${AUTO_RESTART_TIME:-11:59 PM} - - RELOGIN_AFTER_2FA_TIMEOUT=${RELOGIN_AFTER_2FA_TIMEOUT:-yes} + - RELOGIN_AFTER_TWOFA_TIMEOUT=${RELOGIN_AFTER_TWOFA_TIMEOUT:-yes} + - EXISTING_SESSION_DETECTED_ACTION=${EXISTING_SESSION_DETECTED_ACTION:-primaryoverride} - TIME_ZONE=${TIME_ZONE:-America/New_York} ports: - - "127.0.0.1:4001:4001" - - "127.0.0.1:4002:4002" + - "127.0.0.1:4001:4003" + - "127.0.0.1:4002:4004" - "127.0.0.1:5900:5900" + volumes: + - ./secrets:/run/secrets:ro healthcheck: - test: ["CMD-SHELL", "nc -z localhost 4002 || exit 1"] + test: ["CMD-SHELL", "bash -lc ': >/dev/tcp/127.0.0.1/4004' || bash -lc ': >/dev/tcp/127.0.0.1/4003' || exit 1"] interval: 30s timeout: 10s retries: 5 @@ -28,15 +30,18 @@ services: ibkr-dashboard: build: . restart: unless-stopped - env_file: .env + env_file: + - ${COMPOSE_APP_ENV_FILE:-docker.env} environment: - IBKR_HOST=ib-gateway - - IBKR_PORT=4002 + - IBKR_PORT=4004 + - DB_PATH=/app/data/trades.db # Port exposed only for local testing — can be removed when accessed exclusively via Traefik ports: - "127.0.0.1:8000:8000" volumes: - - ./trades.db:/app/trades.db + - ./data:/app/data + - ./secrets:/run/secrets:ro depends_on: ib-gateway: condition: service_healthy diff --git a/docker.env.example b/docker.env.example new file mode 100644 index 0000000..cbdb5ba --- /dev/null +++ b/docker.env.example @@ -0,0 +1,25 @@ +# Copy to docker.env for Docker Compose usage. +# Keep raw secrets in ./secrets files instead of this env file. + +TWS_USERID=your_ibkr_username +TWS_PASSWORD_FILE=/run/secrets/tws_password +TRADING_MODE=paper +# Optional if you need VNC access: +# VNC_SERVER_PASSWORD_FILE=/run/secrets/vnc_password +TWOFA_TIMEOUT_ACTION=restart +AUTO_RESTART_TIME=11:59 PM +RELOGIN_AFTER_TWOFA_TIMEOUT=yes +EXISTING_SESSION_DETECTED_ACTION=primaryoverride +TIME_ZONE=Europe/Istanbul + +WEBHOOK_SECRET_FILE=/run/secrets/webhook_secret +# Optional UI auth: +# UI_USERNAME_FILE=/run/secrets/ui_username +# UI_PASSWORD_FILE=/run/secrets/ui_password +IBKR_CLIENT_ID=1 +MAX_DAILY_LOSS=500.0 +MAX_POSITIONS=5 +MAX_ORDER_VALUE=10000.0 +TRAEFIK_NETWORK=traefik-public +TRAEFIK_HOST=ibkr.your-tailnet.ts.net +TRAEFIK_ENTRYPOINT=websecure diff --git a/main.py b/main.py index d27360f..f3c47de 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse, Response from fastapi.staticfiles import StaticFiles from ib_async import IB +from config_utils import read_env_or_file import dependencies from init_db import ensure_database from routers import charts, health, portfolio, scanner, trades @@ -29,8 +30,8 @@ IBKR_PORT = int(os.getenv("IBKR_PORT", 7497)) IBKR_CLIENT_ID = int(os.getenv("IBKR_CLIENT_ID", 1)) DB_PATH = os.getenv("DB_PATH", "trades.db") -UI_USERNAME = os.getenv("UI_USERNAME", "") -UI_PASSWORD = os.getenv("UI_PASSWORD", "") +UI_USERNAME = read_env_or_file("UI_USERNAME", "") or "" +UI_PASSWORD = read_env_or_file("UI_PASSWORD", "") or "" AUTH_ENABLED = bool(UI_USERNAME and UI_PASSWORD) _DEFAULT_SECRET = "your_super_secret_string_123" @@ -48,7 +49,7 @@ ib = IB() def _validate_config() -> None: - webhook_secret = os.getenv("WEBHOOK_SECRET", "") + webhook_secret = read_env_or_file("WEBHOOK_SECRET", "") or "" if not webhook_secret or webhook_secret == _DEFAULT_SECRET: logger.warning( "SECURITY: WEBHOOK_SECRET is unset or still the default value. " @@ -58,7 +59,7 @@ def _validate_config() -> None: logger.info("UI Basic Auth is ENABLED") else: logger.info( - "UI Basic Auth is DISABLED — set UI_USERNAME + UI_PASSWORD in .env to enable" + "UI Basic Auth is DISABLED — set UI_USERNAME/UI_PASSWORD or *_FILE to enable" ) diff --git a/routers/portfolio.py b/routers/portfolio.py index da37fc6..5791908 100644 --- a/routers/portfolio.py +++ b/routers/portfolio.py @@ -1,6 +1,10 @@ +import os +import time + from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from ib_async import IB import dependencies @@ -8,6 +12,56 @@ router = APIRouter(prefix="/portfolio") templates = Jinja2Templates(directory="templates") +def _extract_summary_values(summary) -> dict: + keys = {"NetLiquidation", "TotalCashValue", "UnrealizedPnL", "RealizedPnL"} + result = {} + for item in summary: + if item.tag in keys: + try: + result[item.tag] = float(item.value) + except (ValueError, TypeError): + result[item.tag] = item.value + return result + + +def _extract_cached_account_values(ib) -> dict: + getter = getattr(ib, "accountValues", None) + if getter is None: + return {} + return _extract_summary_values(getter() or []) + + +async def _read_summary_values(ib) -> dict: + result = _extract_cached_account_values(ib) + if result: + return result + + summary = await ib.reqAccountSummaryAsync() + result = _extract_summary_values(summary) + if result: + return result + + return _extract_cached_account_values(ib) + + +async def _fetch_summary_with_fallback() -> dict: + ib = dependencies.get_ib() + result = await _read_summary_values(ib) + if result: + return result + + # A fresh client recovers account summary when the shared session returns an empty snapshot. + fallback_ib = IB() + host = os.getenv("IBKR_HOST", "127.0.0.1") + port = int(os.getenv("IBKR_PORT", "7497")) + client_id = int(time.time()) % 100000 + 5000 + try: + await fallback_ib.connectAsync(host, port, clientId=client_id) + return await _read_summary_values(fallback_ib) + finally: + fallback_ib.disconnect() + + @router.get("", response_class=HTMLResponse) async def portfolio_page(request: Request): return templates.TemplateResponse(request, "portfolio.html", {}) @@ -33,16 +87,6 @@ async def portfolio_data(): @router.get("/pnl") async def portfolio_pnl(): try: - ib = dependencies.get_ib() - summary = await ib.reqAccountSummaryAsync() - keys = {"NetLiquidation", "TotalCashValue", "UnrealizedPnL", "RealizedPnL"} - result = {} - for item in summary: - if item.tag in keys: - try: - result[item.tag] = float(item.value) - except (ValueError, TypeError): - result[item.tag] = item.value - return result + return await _fetch_summary_with_fallback() except Exception as e: return {"error": str(e)} diff --git a/routers/trades.py b/routers/trades.py index 1d82ced..5d32ecb 100644 --- a/routers/trades.py +++ b/routers/trades.py @@ -8,6 +8,7 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from ib_async import LimitOrder, MarketOrder, Stock +from config_utils import read_env_or_file import dependencies from risk_manager import RiskManager @@ -15,7 +16,10 @@ router = APIRouter() templates = Jinja2Templates(directory="templates") logger = logging.getLogger(__name__) -WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your_super_secret_string_123") +WEBHOOK_SECRET = ( + read_env_or_file("WEBHOOK_SECRET", "your_super_secret_string_123") + or "your_super_secret_string_123" +) risk = RiskManager() diff --git a/secrets/.gitkeep b/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..9e7dee1 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,20 @@ +# Docker Secret Files + +Create local secret files in this directory before starting the Docker stack. + +Required: +- `tws_password` +- `webhook_secret` + +Optional: +- `vnc_password` +- `ui_username` +- `ui_password` + +Example: + +```bash +printf '%s\n' 'your_ibkr_password' > secrets/tws_password +printf '%s\n' 'change_this_webhook_secret' > secrets/webhook_secret +chmod 600 secrets/tws_password secrets/webhook_secret +``` diff --git a/tests/test_integration.py b/tests/test_integration.py index 1b39fe2..f263e35 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,14 +1,11 @@ """ Integration test suite — pre-Docker validation. -Run all tests (mocked IB): +Run offline tests only: pytest tests/test_integration.py -v Run with live IB Gateway on port 4002: - pytest tests/test_integration.py -v (SKIP_LIVE_TESTS must NOT be set) - -Skip live tests: - SKIP_LIVE_TESTS=1 pytest tests/test_integration.py -v + RUN_LIVE_IB_TESTS=1 pytest tests/test_integration.py -v """ import os import sqlite3 @@ -22,7 +19,7 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent)) -SKIP_LIVE = os.getenv("SKIP_LIVE_TESTS", "0") == "1" +RUN_LIVE = os.getenv("RUN_LIVE_IB_TESTS", "0") == "1" # ── Minimal ib_async stubs (used when real ib_async not available) ──────────── def _make_stub(): @@ -110,11 +107,12 @@ class TestIBKRConnection: def test_ibkr_port_env_set(self): port = int(os.getenv("IBKR_PORT", "4002")) - assert port in (4001, 4002), ( - f"IBKR_PORT must be 4001 (live) or 4002 (paper), got {port}" + assert port in (4001, 4002, 4003, 4004), ( + "IBKR_PORT must be 4001/4003 (live) or 4002/4004 (paper), " + f"got {port}" ) - @pytest.mark.skipif(SKIP_LIVE, reason="SKIP_LIVE_TESTS=1") + @pytest.mark.skipif(not RUN_LIVE, reason="RUN_LIVE_IB_TESTS=1 required") def test_ibkr_connection_live(self): import asyncio @@ -144,7 +142,7 @@ class TestIBKRConnection: assert ib.isConnected(), "IB Gateway connection not established" ib.disconnect() - @pytest.mark.skipif(SKIP_LIVE, reason="SKIP_LIVE_TESTS=1") + @pytest.mark.skipif(not RUN_LIVE, reason="RUN_LIVE_IB_TESTS=1 required") def test_history_returns_data(self): r = client.get("/history?symbol=AAPL") assert r.status_code == 200 diff --git a/tests/test_phase2.py b/tests/test_phase2.py index 542a1b0..8c96f76 100644 --- a/tests/test_phase2.py +++ b/tests/test_phase2.py @@ -2,6 +2,7 @@ Phase 2 test suite — uses TestClient with mocked IB and DB dependencies. Run: pytest tests/test_phase2.py -v """ +import os import sqlite3 import sys import threading @@ -88,7 +89,7 @@ _app.include_router(portfolio.router) client = TestClient(_app, raise_server_exceptions=False) WRONG_SECRET = "wrong_secret" -RIGHT_SECRET = "your_super_secret_string_123" +RIGHT_SECRET = os.getenv("WEBHOOK_SECRET", "your_super_secret_string_123") # ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/verify_docker.sh b/verify_docker.sh index de6c6bd..3a912f2 100755 --- a/verify_docker.sh +++ b/verify_docker.sh @@ -3,6 +3,7 @@ # Usage: chmod +x verify_docker.sh && ./verify_docker.sh # Requires: docker compose up -d already running # Note: tests against port 8000 directly (local testing without Traefik hostname) +# Optional: set COMPOSE_ENV_FILE to override the default compose env file. set -uo pipefail @@ -10,6 +11,14 @@ BASE="http://localhost:8000" PASS=0 FAIL=0 +default_compose_env_file=".env" +if [ -f "docker.env" ]; then + default_compose_env_file="docker.env" +fi + +COMPOSE_ENV_FILE=${COMPOSE_ENV_FILE:-$default_compose_env_file} +COMPOSE_APP_ENV_FILE=${COMPOSE_APP_ENV_FILE:-$COMPOSE_ENV_FILE} + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -19,6 +28,14 @@ pass() { echo -e "${GREEN}[PASS]${NC} $1"; PASS=$((PASS + 1)); } fail() { echo -e "${RED}[FAIL]${NC} $1"; FAIL=$((FAIL + 1)); } info() { echo -e "${YELLOW}[INFO]${NC} $1"; } +compose() { + if [ -n "$COMPOSE_ENV_FILE" ] && [ -f "$COMPOSE_ENV_FILE" ]; then + COMPOSE_APP_ENV_FILE="$COMPOSE_APP_ENV_FILE" docker compose --env-file "$COMPOSE_ENV_FILE" "$@" + else + COMPOSE_APP_ENV_FILE="$COMPOSE_APP_ENV_FILE" docker compose "$@" + fi +} + check_body() { local label="$1" result="$2" expected="$3" if echo "$result" | grep -q "$expected"; then @@ -127,17 +144,22 @@ fi echo "" echo "── IBKR Connection Log Check ─────────────────────" -if docker compose logs ibkr-dashboard 2>/dev/null | grep -q "Connected to IB"; then +if compose logs ibkr-dashboard 2>/dev/null | grep -q "Connected to IB"; then pass "IBKR connection confirmed in logs" else - fail "IBKR connection confirmed in logs" - info "Check: docker compose logs ibkr-dashboard" + R=$(curl -s --max-time 10 "$BASE/health" 2>/dev/null || echo "{}") + if echo "$R" | grep -q '"ib_connected":true'; then + pass "IBKR connection confirmed via /health" + else + fail "IBKR connection confirmed in logs" + info "Check: compose logs ibkr-dashboard" + fi fi echo "" echo "── pytest Inside Container ───────────────────────" info "Running: docker compose exec ibkr-dashboard pytest tests/ -q --tb=short" -if docker compose exec ibkr-dashboard pytest tests/ -q --tb=short 2>&1; then +if compose exec ibkr-dashboard pytest tests/ -q --tb=short 2>&1; then pass "pytest inside container" else fail "pytest inside container"