Enhance Docker support and configuration
- Updated .env.example to recommend using docker.env and secrets for sensitive data. - Modified .gitignore to include docker.env and secrets directory. - Adjusted docker-compose.yml to utilize environment files and updated port mappings. - Implemented read_env_or_file utility for better environment variable management. - Refactored portfolio and trades routers to use the new utility for fetching environment variables. - Added integration and phase 2 test updates for new environment variable handling. - Created new documentation for Docker operations and secret management. - Added placeholder files for Docker secrets and configuration.
This commit is contained in:
@@ -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
|
||||||
+5
-3
@@ -1,3 +1,4 @@
|
|||||||
|
# For Docker Compose, prefer docker.env.example + ./secrets instead of this file.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# IB Gateway (gnzsnz/ib-gateway:stable)
|
# IB Gateway (gnzsnz/ib-gateway:stable)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -7,17 +8,18 @@ TRADING_MODE=paper # paper | live
|
|||||||
VNC_SERVER_PASSWORD=your_vnc_password
|
VNC_SERVER_PASSWORD=your_vnc_password
|
||||||
TWOFA_TIMEOUT_ACTION=restart # restart | exit
|
TWOFA_TIMEOUT_ACTION=restart # restart | exit
|
||||||
AUTO_RESTART_TIME=11:59 PM
|
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
|
TIME_ZONE=Europe/Istanbul # e.g. America/New_York, Europe/London
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# FastAPI Application
|
# FastAPI Application
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
IBKR_HOST=ib-gateway # Docker service name; use 127.0.0.1 for local
|
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
|
IBKR_CLIENT_ID=1
|
||||||
WEBHOOK_SECRET=change_this_to_a_strong_random_secret # openssl rand -hex 32
|
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
|
# Risk Management
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ venv/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
docker.env
|
||||||
|
secrets/*
|
||||||
|
!secrets/.gitkeep
|
||||||
|
!secrets/README.md
|
||||||
|
|||||||
+174
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
+15
-10
@@ -3,21 +3,23 @@ services:
|
|||||||
ib-gateway:
|
ib-gateway:
|
||||||
image: ghcr.io/gnzsnz/ib-gateway:stable
|
image: ghcr.io/gnzsnz/ib-gateway:stable
|
||||||
restart: always
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- ${COMPOSE_APP_ENV_FILE:-docker.env}
|
||||||
environment:
|
environment:
|
||||||
- TWS_USERID=${TWS_USERID}
|
|
||||||
- TWS_PASSWORD=${TWS_PASSWORD}
|
|
||||||
- TRADING_MODE=${TRADING_MODE:-paper}
|
- TRADING_MODE=${TRADING_MODE:-paper}
|
||||||
- VNC_SERVER_PASSWORD=${VNC_SERVER_PASSWORD}
|
|
||||||
- TWOFA_TIMEOUT_ACTION=${TWOFA_TIMEOUT_ACTION:-restart}
|
- TWOFA_TIMEOUT_ACTION=${TWOFA_TIMEOUT_ACTION:-restart}
|
||||||
- AUTO_RESTART_TIME=${AUTO_RESTART_TIME:-11:59 PM}
|
- 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}
|
- TIME_ZONE=${TIME_ZONE:-America/New_York}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:4001:4001"
|
- "127.0.0.1:4001:4003"
|
||||||
- "127.0.0.1:4002:4002"
|
- "127.0.0.1:4002:4004"
|
||||||
- "127.0.0.1:5900:5900"
|
- "127.0.0.1:5900:5900"
|
||||||
|
volumes:
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -28,15 +30,18 @@ services:
|
|||||||
ibkr-dashboard:
|
ibkr-dashboard:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file:
|
||||||
|
- ${COMPOSE_APP_ENV_FILE:-docker.env}
|
||||||
environment:
|
environment:
|
||||||
- IBKR_HOST=ib-gateway
|
- 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
|
# Port exposed only for local testing — can be removed when accessed exclusively via Traefik
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./trades.db:/app/trades.db
|
- ./data:/app/data
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
ib-gateway:
|
ib-gateway:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse, Response
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from ib_async import IB
|
from ib_async import IB
|
||||||
|
|
||||||
|
from config_utils import read_env_or_file
|
||||||
import dependencies
|
import dependencies
|
||||||
from init_db import ensure_database
|
from init_db import ensure_database
|
||||||
from routers import charts, health, portfolio, scanner, trades
|
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))
|
IBKR_CLIENT_ID = int(os.getenv("IBKR_CLIENT_ID", 1))
|
||||||
DB_PATH = os.getenv("DB_PATH", "trades.db")
|
DB_PATH = os.getenv("DB_PATH", "trades.db")
|
||||||
|
|
||||||
UI_USERNAME = os.getenv("UI_USERNAME", "")
|
UI_USERNAME = read_env_or_file("UI_USERNAME", "") or ""
|
||||||
UI_PASSWORD = os.getenv("UI_PASSWORD", "")
|
UI_PASSWORD = read_env_or_file("UI_PASSWORD", "") or ""
|
||||||
AUTH_ENABLED = bool(UI_USERNAME and UI_PASSWORD)
|
AUTH_ENABLED = bool(UI_USERNAME and UI_PASSWORD)
|
||||||
|
|
||||||
_DEFAULT_SECRET = "your_super_secret_string_123"
|
_DEFAULT_SECRET = "your_super_secret_string_123"
|
||||||
@@ -48,7 +49,7 @@ ib = IB()
|
|||||||
|
|
||||||
|
|
||||||
def _validate_config() -> None:
|
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:
|
if not webhook_secret or webhook_secret == _DEFAULT_SECRET:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SECURITY: WEBHOOK_SECRET is unset or still the default value. "
|
"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")
|
logger.info("UI Basic Auth is ENABLED")
|
||||||
else:
|
else:
|
||||||
logger.info(
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+55
-11
@@ -1,6 +1,10 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from ib_async import IB
|
||||||
|
|
||||||
import dependencies
|
import dependencies
|
||||||
|
|
||||||
@@ -8,6 +12,56 @@ router = APIRouter(prefix="/portfolio")
|
|||||||
templates = Jinja2Templates(directory="templates")
|
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)
|
@router.get("", response_class=HTMLResponse)
|
||||||
async def portfolio_page(request: Request):
|
async def portfolio_page(request: Request):
|
||||||
return templates.TemplateResponse(request, "portfolio.html", {})
|
return templates.TemplateResponse(request, "portfolio.html", {})
|
||||||
@@ -33,16 +87,6 @@ async def portfolio_data():
|
|||||||
@router.get("/pnl")
|
@router.get("/pnl")
|
||||||
async def portfolio_pnl():
|
async def portfolio_pnl():
|
||||||
try:
|
try:
|
||||||
ib = dependencies.get_ib()
|
return await _fetch_summary_with_fallback()
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|||||||
+5
-1
@@ -8,6 +8,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from ib_async import LimitOrder, MarketOrder, Stock
|
from ib_async import LimitOrder, MarketOrder, Stock
|
||||||
|
|
||||||
|
from config_utils import read_env_or_file
|
||||||
import dependencies
|
import dependencies
|
||||||
from risk_manager import RiskManager
|
from risk_manager import RiskManager
|
||||||
|
|
||||||
@@ -15,7 +16,10 @@ router = APIRouter()
|
|||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
logger = logging.getLogger(__name__)
|
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()
|
risk = RiskManager()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Integration test suite — pre-Docker validation.
|
Integration test suite — pre-Docker validation.
|
||||||
|
|
||||||
Run all tests (mocked IB):
|
Run offline tests only:
|
||||||
pytest tests/test_integration.py -v
|
pytest tests/test_integration.py -v
|
||||||
|
|
||||||
Run with live IB Gateway on port 4002:
|
Run with live IB Gateway on port 4002:
|
||||||
pytest tests/test_integration.py -v (SKIP_LIVE_TESTS must NOT be set)
|
RUN_LIVE_IB_TESTS=1 pytest tests/test_integration.py -v
|
||||||
|
|
||||||
Skip live tests:
|
|
||||||
SKIP_LIVE_TESTS=1 pytest tests/test_integration.py -v
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -22,7 +19,7 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
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) ────────────
|
# ── Minimal ib_async stubs (used when real ib_async not available) ────────────
|
||||||
def _make_stub():
|
def _make_stub():
|
||||||
@@ -110,11 +107,12 @@ class TestIBKRConnection:
|
|||||||
|
|
||||||
def test_ibkr_port_env_set(self):
|
def test_ibkr_port_env_set(self):
|
||||||
port = int(os.getenv("IBKR_PORT", "4002"))
|
port = int(os.getenv("IBKR_PORT", "4002"))
|
||||||
assert port in (4001, 4002), (
|
assert port in (4001, 4002, 4003, 4004), (
|
||||||
f"IBKR_PORT must be 4001 (live) or 4002 (paper), got {port}"
|
"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):
|
def test_ibkr_connection_live(self):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@@ -144,7 +142,7 @@ class TestIBKRConnection:
|
|||||||
assert ib.isConnected(), "IB Gateway connection not established"
|
assert ib.isConnected(), "IB Gateway connection not established"
|
||||||
ib.disconnect()
|
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):
|
def test_history_returns_data(self):
|
||||||
r = client.get("/history?symbol=AAPL")
|
r = client.get("/history?symbol=AAPL")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Phase 2 test suite — uses TestClient with mocked IB and DB dependencies.
|
Phase 2 test suite — uses TestClient with mocked IB and DB dependencies.
|
||||||
Run: pytest tests/test_phase2.py -v
|
Run: pytest tests/test_phase2.py -v
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -88,7 +89,7 @@ _app.include_router(portfolio.router)
|
|||||||
client = TestClient(_app, raise_server_exceptions=False)
|
client = TestClient(_app, raise_server_exceptions=False)
|
||||||
|
|
||||||
WRONG_SECRET = "wrong_secret"
|
WRONG_SECRET = "wrong_secret"
|
||||||
RIGHT_SECRET = "your_super_secret_string_123"
|
RIGHT_SECRET = os.getenv("WEBHOOK_SECRET", "your_super_secret_string_123")
|
||||||
|
|
||||||
|
|
||||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+26
-4
@@ -3,6 +3,7 @@
|
|||||||
# Usage: chmod +x verify_docker.sh && ./verify_docker.sh
|
# Usage: chmod +x verify_docker.sh && ./verify_docker.sh
|
||||||
# Requires: docker compose up -d already running
|
# Requires: docker compose up -d already running
|
||||||
# Note: tests against port 8000 directly (local testing without Traefik hostname)
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -10,6 +11,14 @@ BASE="http://localhost:8000"
|
|||||||
PASS=0
|
PASS=0
|
||||||
FAIL=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'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
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)); }
|
fail() { echo -e "${RED}[FAIL]${NC} $1"; FAIL=$((FAIL + 1)); }
|
||||||
info() { echo -e "${YELLOW}[INFO]${NC} $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() {
|
check_body() {
|
||||||
local label="$1" result="$2" expected="$3"
|
local label="$1" result="$2" expected="$3"
|
||||||
if echo "$result" | grep -q "$expected"; then
|
if echo "$result" | grep -q "$expected"; then
|
||||||
@@ -127,17 +144,22 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "── IBKR Connection Log Check ─────────────────────"
|
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"
|
pass "IBKR connection confirmed in logs"
|
||||||
else
|
else
|
||||||
fail "IBKR connection confirmed in logs"
|
R=$(curl -s --max-time 10 "$BASE/health" 2>/dev/null || echo "{}")
|
||||||
info "Check: docker compose logs ibkr-dashboard"
|
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
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "── pytest Inside Container ───────────────────────"
|
echo "── pytest Inside Container ───────────────────────"
|
||||||
info "Running: docker compose exec ibkr-dashboard pytest tests/ -q --tb=short"
|
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"
|
pass "pytest inside container"
|
||||||
else
|
else
|
||||||
fail "pytest inside container"
|
fail "pytest inside container"
|
||||||
|
|||||||
Reference in New Issue
Block a user