From 0096c8819bc8f91e9aba1239829a37f19814b414 Mon Sep 17 00:00:00 2001 From: coddard Date: Mon, 18 May 2026 00:31:08 +0300 Subject: [PATCH] initial commit --- .env.example | 43 ++++ .gitignore | 12 + Dockerfile | 17 ++ dependencies.py | 59 +++++ docker-compose.yml | 73 ++++++ init_db.py | 26 ++ main.py | 153 ++++++++++++ requirements.txt | 8 + risk_manager.py | 79 ++++++ routers/__init__.py | 0 routers/charts.py | 175 ++++++++++++++ routers/health.py | 37 +++ routers/portfolio.py | 48 ++++ routers/scanner.py | 109 +++++++++ routers/trades.py | 171 +++++++++++++ static/styles.css | 493 ++++++++++++++++++++++++++++++++++++++ templates/index.html | 93 +++++++ templates/layout.html | 29 +++ templates/portfolio.html | 108 +++++++++ templates/scanner.html | 215 +++++++++++++++++ templates/tradelog.html | 41 ++++ tests/__init__.py | 0 tests/test_integration.py | 322 +++++++++++++++++++++++++ tests/test_phase2.py | 224 +++++++++++++++++ verify_docker.sh | 159 ++++++++++++ verify_phase2.sh | 157 ++++++++++++ 26 files changed, 2851 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 dependencies.py create mode 100644 docker-compose.yml create mode 100644 init_db.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 risk_manager.py create mode 100644 routers/__init__.py create mode 100644 routers/charts.py create mode 100644 routers/health.py create mode 100644 routers/portfolio.py create mode 100644 routers/scanner.py create mode 100644 routers/trades.py create mode 100644 static/styles.css create mode 100644 templates/index.html create mode 100644 templates/layout.html create mode 100644 templates/portfolio.html create mode 100644 templates/scanner.html create mode 100644 templates/tradelog.html create mode 100644 tests/__init__.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_phase2.py create mode 100755 verify_docker.sh create mode 100755 verify_phase2.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6213988 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# ───────────────────────────────────────────────────────────────────────────── +# IB Gateway (gnzsnz/ib-gateway:stable) +# ───────────────────────────────────────────────────────────────────────────── +TWS_USERID=your_ibkr_username +TWS_PASSWORD=your_ibkr_password +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 +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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad5f42c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +trades.db +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo +venv/ +.venv/ +*.egg-info/ +dist/ +build/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a986de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + netcat-openbsd \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/dependencies.py b/dependencies.py new file mode 100644 index 0000000..1349e0c --- /dev/null +++ b/dependencies.py @@ -0,0 +1,59 @@ +import sqlite3 +import threading +from typing import Optional +from ib_async import IB + +_ib_instance: Optional[IB] = None +_db_conn: Optional[sqlite3.Connection] = None +_db_cursor: Optional[sqlite3.Cursor] = None +_db_write_lock: Optional[threading.Lock] = None + + +def get_ib() -> IB: + if _ib_instance is None: + raise RuntimeError("IB instance not initialized") + return _ib_instance + + +def set_ib_instance(ib: IB): + global _ib_instance + _ib_instance = ib + + +def get_db_conn() -> sqlite3.Connection: + if _db_conn is None: + raise RuntimeError("Database connection not initialized") + return _db_conn + + +def get_db_cursor() -> sqlite3.Cursor: + if _db_cursor is None: + raise RuntimeError("Database cursor not initialized") + return _db_cursor + + +def get_db_lock() -> threading.Lock: + if _db_write_lock is None: + raise RuntimeError("Database write lock not initialized") + return _db_write_lock + + +def set_database_dependencies( + conn: sqlite3.Connection, + cursor: sqlite3.Cursor, + lock: threading.Lock, +): + global _db_conn, _db_cursor, _db_write_lock + _db_conn = conn + _db_cursor = cursor + _db_write_lock = lock + + +def setup_dependencies( + ib: IB, + conn: sqlite3.Connection, + cursor: sqlite3.Cursor, + lock: threading.Lock, +): + set_ib_instance(ib) + set_database_dependencies(conn, cursor, lock) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ad4d51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +services: + + ib-gateway: + image: ghcr.io/gnzsnz/ib-gateway:stable + restart: always + 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} + - TIME_ZONE=${TIME_ZONE:-America/New_York} + ports: + - "127.0.0.1:4001:4001" + - "127.0.0.1:4002:4002" + - "127.0.0.1:5900:5900" + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 4002 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + networks: + - ibkr-net + + ibkr-dashboard: + build: . + restart: unless-stopped + env_file: .env + environment: + - IBKR_HOST=ib-gateway + - IBKR_PORT=4002 + # 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 + depends_on: + ib-gateway: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik-public}" + # Router + - "traefik.http.routers.ibkr-dashboard.rule=Host(`${TRAEFIK_HOST:-ibkr.local}`)" + - "traefik.http.routers.ibkr-dashboard.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.ibkr-dashboard.tls=true" + - "traefik.http.routers.ibkr-dashboard.middlewares=ibkr-sse-headers@docker" + # Service — port 8000 with immediate SSE flush + - "traefik.http.services.ibkr-dashboard.loadbalancer.server.port=8000" + - "traefik.http.services.ibkr-dashboard.loadbalancer.responseforwarding.flushinterval=-1" + - "traefik.http.services.ibkr-dashboard.loadbalancer.healthcheck.path=/health" + - "traefik.http.services.ibkr-dashboard.loadbalancer.healthcheck.interval=30s" + # Middleware: strip X-Accel-Buffering to prevent any upstream buffering of SSE + - "traefik.http.middlewares.ibkr-sse-headers.headers.customResponseHeaders.X-Accel-Buffering=no" + networks: + - ibkr-net + - traefik-public + +networks: + ibkr-net: + driver: bridge + traefik-public: + external: true + name: ${TRAEFIK_NETWORK:-traefik-public} diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..eacc5e2 --- /dev/null +++ b/init_db.py @@ -0,0 +1,26 @@ +import logging +import sqlite3 +import threading + +logger = logging.getLogger(__name__) + + +def ensure_database(db_path="trades.db"): + logger.info(f"Ensuring database exists at: {db_path}") + conn = sqlite3.connect(db_path, check_same_thread=False) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + symbol TEXT NOT NULL, + action TEXT NOT NULL, + quantity INTEGER NOT NULL, + price REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + logger.info("Database is ready.") + db_write_lock = threading.Lock() + return conn, cursor, db_write_lock diff --git a/main.py b/main.py new file mode 100644 index 0000000..d27360f --- /dev/null +++ b/main.py @@ -0,0 +1,153 @@ +import asyncio +import base64 +import logging +import os +import secrets +import warnings +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response +from fastapi.staticfiles import StaticFiles +from ib_async import IB + +import dependencies +from init_db import ensure_database +from routers import charts, health, portfolio, scanner, trades + +load_dotenv() + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s — %(message)s", +) +logger = logging.getLogger(__name__) + +IBKR_HOST = os.getenv("IBKR_HOST", "127.0.0.1") +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", "") +AUTH_ENABLED = bool(UI_USERNAME and UI_PASSWORD) + +_DEFAULT_SECRET = "your_super_secret_string_123" + +worker_count = int(os.getenv("WEB_CONCURRENCY", "1")) +if worker_count > 1: + warnings.warn( + "WARNING: Multiple workers! SSE/ChartState will break. Use --workers 1", + RuntimeWarning, + ) + logger.warning("Multiple workers detected — ChartState is not shared across workers!") + +conn, cursor, db_write_lock = ensure_database(DB_PATH) +ib = IB() + + +def _validate_config() -> None: + webhook_secret = os.getenv("WEBHOOK_SECRET", "") + if not webhook_secret or webhook_secret == _DEFAULT_SECRET: + logger.warning( + "SECURITY: WEBHOOK_SECRET is unset or still the default value. " + "Generate a strong secret with: openssl rand -hex 32" + ) + if AUTH_ENABLED: + logger.info("UI Basic Auth is ENABLED") + else: + logger.info( + "UI Basic Auth is DISABLED — set UI_USERNAME + UI_PASSWORD in .env to enable" + ) + + +async def reconnect_loop() -> None: + max_attempts = 10 + for attempt in range(1, max_attempts + 1): + await asyncio.sleep(5) + logger.info(f"Reconnect attempt {attempt}/{max_attempts}...") + try: + await ib.connectAsync(IBKR_HOST, IBKR_PORT, clientId=IBKR_CLIENT_ID) + dependencies.set_ib_instance(ib) + logger.info("Reconnected to IB Gateway successfully.") + return + except Exception as exc: + logger.warning(f"Reconnect attempt {attempt} failed: {exc}") + if attempt < max_attempts: + await asyncio.sleep(30) + logger.error("Max reconnect attempts reached. Manual restart required.") + + +def on_disconnect() -> None: + logger.warning("IB Gateway disconnected. Scheduling reconnect...") + asyncio.create_task(reconnect_loop()) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + _validate_config() + try: + await ib.connectAsync(IBKR_HOST, IBKR_PORT, clientId=IBKR_CLIENT_ID) + logger.info("Connected to IB Gateway") + dependencies.setup_dependencies(ib, conn, cursor, db_write_lock) + ib.disconnectedEvent += on_disconnect + yield + finally: + logger.info("Disconnecting from IB") + ib.disconnect() + conn.close() + logger.info("Database connection closed") + + +app = FastAPI(lifespan=lifespan) + +# UI pages that require Basic Auth when AUTH_ENABLED +_UI_PATHS = {"/", "/scanner", "/tradelog", "/portfolio"} +_PUBLIC_PREFIXES = ( + "/health", + "/webhook", + "/static/", + "/stream", + "/history", + "/subscribe", + "/portfolio/data", + "/portfolio/pnl", + "/risk/", + "/scanner/export", +) + + +@app.middleware("http") +async def basic_auth_middleware(request: Request, call_next): + if not AUTH_ENABLED: + return await call_next(request) + path = request.url.path + if any(path.startswith(p) for p in _PUBLIC_PREFIXES): + return await call_next(request) + if path not in _UI_PATHS: + return await call_next(request) + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Basic "): + try: + decoded = base64.b64decode(auth_header[6:]).decode("utf-8") + username, _, password = decoded.partition(":") + if secrets.compare_digest(username, UI_USERNAME) and secrets.compare_digest( + password, UI_PASSWORD + ): + return await call_next(request) + except Exception: + pass + return Response( + status_code=401, + headers={"WWW-Authenticate": 'Basic realm="IBKR Dashboard"'}, + content="Unauthorized", + ) + + +app.mount("/static", StaticFiles(directory="static"), name="static") +app.include_router(health.router) +app.include_router(charts.router) +app.include_router(scanner.router) +app.include_router(trades.router) +app.include_router(portfolio.router) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e59805a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +ib_async +sse-starlette +jinja2 +python-dotenv +httpx +pytest diff --git a/risk_manager.py b/risk_manager.py new file mode 100644 index 0000000..435cbf1 --- /dev/null +++ b/risk_manager.py @@ -0,0 +1,79 @@ +import asyncio +import logging +import os +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +class RiskManager: + def __init__(self): + self.max_daily_loss = float(os.getenv("MAX_DAILY_LOSS", "500.0")) + self.max_positions = int(os.getenv("MAX_POSITIONS", "5")) + self.max_order_value = float(os.getenv("MAX_ORDER_VALUE", "10000.0")) + + def check_daily_loss(self, cursor) -> bool: + """DB-based realized P&L proxy — used as fallback.""" + today_start = int( + datetime.now(timezone.utc) + .replace(hour=0, minute=0, second=0, microsecond=0) + .timestamp() + ) + cursor.execute( + "SELECT SUM(price * quantity) FROM trades WHERE action='SELL' AND timestamp >= ?", + (today_start,), + ) + sell_value = cursor.fetchone()[0] or 0.0 + cursor.execute( + "SELECT SUM(price * quantity) FROM trades WHERE action='BUY' AND timestamp >= ?", + (today_start,), + ) + buy_value = cursor.fetchone()[0] or 0.0 + realized_loss = buy_value - sell_value + return realized_loss < self.max_daily_loss + + async def check_daily_loss_live(self, ib, cursor) -> bool: + """ + Fetch today's RealizedPnL from IBKR account summary. + Falls back to DB proxy on timeout or API error. + """ + try: + accounts = ib.managedAccounts() + if not accounts: + logger.warning("No managed accounts returned — using DB P&L fallback") + return self.check_daily_loss(cursor) + account = accounts[0] + summary = await asyncio.wait_for(ib.reqAccountSummaryAsync(), timeout=5.0) + for item in summary: + if item.tag == "RealizedPnL" and item.account == account: + realized_pnl = float(item.value) + passed = realized_pnl >= -self.max_daily_loss + if not passed: + logger.warning( + f"Daily loss limit hit: RealizedPnL={realized_pnl:.2f}, " + f"limit=-{self.max_daily_loss:.2f}" + ) + return passed + except asyncio.TimeoutError: + logger.warning("reqAccountSummaryAsync timed out — using DB P&L fallback") + except Exception as exc: + logger.warning(f"Live P&L check failed ({exc}) — using DB P&L fallback") + return self.check_daily_loss(cursor) + + def check_position_count(self, ib) -> bool: + positions = ib.positions() + open_positions = [p for p in positions if p.position != 0] + return len(open_positions) < self.max_positions + + def check_order_value(self, quantity, price) -> bool: + return float(quantity) * float(price) <= self.max_order_value + + async def run_all_checks(self, ib, cursor, quantity, price) -> dict: + failed = [] + if not await self.check_daily_loss_live(ib, cursor): + failed.append("daily_loss_limit_exceeded") + if not self.check_position_count(ib): + failed.append("max_positions_reached") + if not self.check_order_value(quantity, price): + failed.append("order_value_exceeds_limit") + return {"passed": len(failed) == 0, "failed_checks": failed} diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/charts.py b/routers/charts.py new file mode 100644 index 0000000..5ffde0f --- /dev/null +++ b/routers/charts.py @@ -0,0 +1,175 @@ +import asyncio +import json +import logging +import time +from math import floor + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from ib_async import Stock +from sse_starlette.sse import EventSourceResponse + +import dependencies + +router = APIRouter() +templates = Jinja2Templates(directory="templates") +logger = logging.getLogger(__name__) + +_history_cache: dict = {} # {symbol: (monotonic_ts, data)} +_HISTORY_CACHE_TTL = 60 # seconds + + +class ChartState: + def __init__(self): + self.latest_tick = None + self.current_ticker = None + self.current_minute = None + self.current_ohlc = None + + +chart_state = ChartState() + + +@router.get("/", response_class=HTMLResponse) +async def index(request: Request, symbol: str = ""): + return templates.TemplateResponse(request, "index.html", {"symbol": symbol}) + + +@router.get("/history") +async def get_history(symbol: str): + sym = symbol.upper() + now = time.monotonic() + + cached = _history_cache.get(sym) + if cached and (now - cached[0]) < _HISTORY_CACHE_TTL: + return cached[1] + + try: + ib = dependencies.get_ib() + contract = Stock(sym, "SMART", "USD") + qualified = await ib.qualifyContractsAsync(contract) + if not qualified: + return [] + + bars = None + for attempt in range(3): + try: + bars = await ib.reqHistoricalDataAsync( + qualified[0], + endDateTime="", + durationStr="1 D", + barSizeSetting="1 min", + whatToShow="TRADES", + useRTH=True, + formatDate=1, + ) + break + except Exception as exc: + msg = str(exc) + if ("162" in msg or "pacing" in msg.lower()) and attempt < 2: + logger.warning( + f"IBKR pacing violation for {sym}, retry {attempt + 1}/3 in 10s" + ) + await asyncio.sleep(10) + continue + raise + + if bars is None: + return [] + + result = [ + { + "time": int(bar.date.timestamp()), + "open": bar.open, + "high": bar.high, + "low": bar.low, + "close": bar.close, + } + for bar in bars + ] + _history_cache[sym] = (time.monotonic(), result) + return result + except Exception as exc: + logger.error(f"Error fetching history for {sym}: {exc}") + return [] + + +@router.post("/subscribe") +async def subscribe(request: Request): + try: + ib = dependencies.get_ib() + data = await request.json() + symbol = data.get("symbol", "").upper() + if not symbol: + return {"status": "error", "message": "Symbol is required"} + + contract = Stock(symbol, "SMART", "USD") + qualified = await ib.qualifyContractsAsync(contract) + if not qualified: + return {"status": "error", "message": f"Could not qualify contract for {symbol}"} + + if chart_state.current_ticker is not None: + try: + ib.cancelRealTimeBars(chart_state.current_ticker) + except Exception as e: + print(f"Error cancelling real-time bars: {e}") + + chart_state.current_minute = None + chart_state.current_ohlc = None + + ticker = ib.reqRealTimeBars(qualified[0], barSize=5, whatToShow="TRADES", useRTH=True) + chart_state.current_ticker = ticker + + def on_bar(bars, hasNewBar): + if not hasNewBar or not bars: + return + bar = bars[-1] + timestamp = int(bar.time.timestamp()) + minute_bucket = floor(timestamp / 60) * 60 + + if chart_state.current_minute is None: + chart_state.current_minute = minute_bucket + chart_state.current_ohlc = { + "time": minute_bucket, + "open": bar.open_, + "high": bar.high, + "low": bar.low, + "close": bar.close, + } + elif minute_bucket == chart_state.current_minute: + chart_state.current_ohlc["high"] = max(chart_state.current_ohlc["high"], bar.high) + chart_state.current_ohlc["low"] = min(chart_state.current_ohlc["low"], bar.low) + chart_state.current_ohlc["close"] = bar.close + else: + print(f"Finalized candle: {chart_state.current_ohlc}") + chart_state.current_minute = minute_bucket + chart_state.current_ohlc = { + "time": minute_bucket, + "open": bar.open_, + "high": bar.high, + "low": bar.low, + "close": bar.close, + } + + chart_state.latest_tick = chart_state.current_ohlc.copy() + + ticker.updateEvent += on_bar + + return {"status": "ok", "symbol": symbol} + except Exception as e: + print(f"Error subscribing to {symbol}: {e}") + return {"status": "error", "message": str(e)} + + +@router.get("/stream") +async def stream(request: Request): + async def event_generator(): + while True: + if await request.is_disconnected(): + break + if chart_state.latest_tick: + yield {"event": "candle", "data": json.dumps(chart_state.latest_tick)} + await asyncio.sleep(1) + + return EventSourceResponse(event_generator()) diff --git a/routers/health.py b/routers/health.py new file mode 100644 index 0000000..038ca97 --- /dev/null +++ b/routers/health.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +import dependencies + +router = APIRouter() + + +@router.get("/health") +async def health(): + ib_connected = False + account = None + try: + ib_inst = dependencies.get_ib() + ib_connected = ib_inst.isConnected() + accounts = ib_inst.managedAccounts() + account = accounts[0] if accounts else None + except Exception: + pass + + db_ok = False + try: + cur = dependencies.get_db_cursor() + cur.execute("SELECT 1") + db_ok = True + except Exception: + pass + + return JSONResponse( + { + "status": "ok" if (ib_connected and db_ok) else "degraded", + "ib_connected": ib_connected, + "account": account, + "db_ok": db_ok, + "version": "3.0", + } + ) diff --git a/routers/portfolio.py b/routers/portfolio.py new file mode 100644 index 0000000..da37fc6 --- /dev/null +++ b/routers/portfolio.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +import dependencies + +router = APIRouter(prefix="/portfolio") +templates = Jinja2Templates(directory="templates") + + +@router.get("", response_class=HTMLResponse) +async def portfolio_page(request: Request): + return templates.TemplateResponse(request, "portfolio.html", {}) + + +@router.get("/data") +async def portfolio_data(): + try: + ib = dependencies.get_ib() + positions = ib.positions() + return [ + { + "symbol": pos.contract.symbol, + "position": pos.position, + "avg_cost": pos.avgCost, + } + for pos in positions + ] + except Exception as e: + return {"error": str(e)} + + +@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 + except Exception as e: + return {"error": str(e)} diff --git a/routers/scanner.py b/routers/scanner.py new file mode 100644 index 0000000..37d3846 --- /dev/null +++ b/routers/scanner.py @@ -0,0 +1,109 @@ +import asyncio +import csv +import io +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from ib_async import ScannerSubscription + +import dependencies + +router = APIRouter(prefix="/scanner") +templates = Jinja2Templates(directory="templates") +logger = logging.getLogger(__name__) + + +async def execute_scan(data: dict, ib) -> list: + scan_type = data.get("scan_type", "HOT_BY_VOLUME") + min_price = float(data.get("min_price", 0) or 0) + min_volume = int(data.get("min_volume", 0) or 0) + min_market_cap = float(data.get("min_market_cap", 0) or 0) + + subscription = ScannerSubscription() + subscription.instrument = "STK" + subscription.locationCode = "STK.US.MAJOR" + subscription.stockTypeFilter = "ALL" + subscription.scanCode = scan_type + + if min_price > 0: + subscription.abovePrice = min_price + if min_volume > 0: + subscription.aboveVolume = min_volume + if min_market_cap > 0: + subscription.marketCapAbove = min_market_cap + + scan_data = await asyncio.wait_for( + ib.reqScannerDataAsync(subscription), timeout=30.0 + ) + + results = [] + for item in scan_data[:20]: + contract = item.contractDetails.contract + symbol = contract.symbol + try: + details_list = await ib.reqContractDetailsAsync(contract) + long_name = details_list[0].longName if details_list else None + except Exception: + long_name = None + + results.append({ + "symbol": symbol, + "company": long_name or contract.localSymbol or f"{symbol} Inc.", + "exchange": contract.primaryExchange or contract.exchange, + "chart_image": f"https://finviz.com/chart.ashx?t={symbol}&ty=c&ta=1&p=d&s=l", + "rank": item.rank if hasattr(item, "rank") else None, + }) + + return results + + +@router.get("", response_class=HTMLResponse) +async def scanner_page(request: Request): + return templates.TemplateResponse(request, "scanner.html", {}) + + +@router.post("") +async def run_scanner(request: Request): + try: + ib = dependencies.get_ib() + data = await request.json() + results = await execute_scan(data, ib) + return {"status": "ok", "results": results} + except asyncio.TimeoutError: + return {"status": "error", "message": "Scanner request timed out"} + except Exception as e: + logger.error(f"Scanner error: {e}") + return {"status": "error", "message": str(e)} + + +@router.post("/export") +async def export_scanner_csv(request: Request): + try: + ib = dependencies.get_ib() + data = await request.json() + scan_type = data.get("scan_type", "HOT_BY_VOLUME") + results = await execute_scan(data, ib) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["Symbol", "Company", "Exchange", "Scan Type", "Timestamp"]) + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + for r in results: + writer.writerow([r["symbol"], r["company"], r["exchange"], scan_type, ts]) + + filename = "scanner_" + datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + ".csv" + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + except asyncio.TimeoutError: + return {"status": "error", "message": "Scanner request timed out"} + except Exception as e: + logger.error(f"Scanner export error: {e}") + return {"status": "error", "message": str(e)} diff --git a/routers/trades.py b/routers/trades.py new file mode 100644 index 0000000..1d82ced --- /dev/null +++ b/routers/trades.py @@ -0,0 +1,171 @@ +import asyncio +import logging +import os +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from ib_async import LimitOrder, MarketOrder, Stock + +import dependencies +from risk_manager import RiskManager + +router = APIRouter() +templates = Jinja2Templates(directory="templates") +logger = logging.getLogger(__name__) + +WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your_super_secret_string_123") +risk = RiskManager() + + +def log_trade(symbol, action, quantity, price, cursor, conn, lock): + timestamp = int(datetime.now(timezone.utc).timestamp()) + with lock: + cursor.execute( + "INSERT INTO trades (timestamp, symbol, action, quantity, price) VALUES (?, ?, ?, ?, ?)", + (timestamp, symbol, action, quantity, price), + ) + conn.commit() + logger.info(f"Logged trade: {action} {quantity} {symbol} @ ${price:.2f}") + + +@router.get("/tradelog", response_class=HTMLResponse) +async def tradelog(request: Request): + cursor = dependencies.get_db_cursor() + cursor.execute( + "SELECT timestamp, symbol, action, quantity, price FROM trades ORDER BY timestamp DESC" + ) + rows = cursor.fetchall() + trades = [ + { + "timestamp": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S"), + "symbol": symbol, + "action": action, + "quantity": quantity, + "price": price, + } + for ts, symbol, action, quantity, price in rows + ] + return templates.TemplateResponse(request, "tradelog.html", {"trades": trades}) + + +@router.get("/risk/status") +async def risk_status(): + try: + ib = dependencies.get_ib() + cursor = dependencies.get_db_cursor() + positions = ib.positions() + open_count = len([p for p in positions if p.position != 0]) + return { + "limits": { + "max_daily_loss": risk.max_daily_loss, + "max_positions": risk.max_positions, + "max_order_value": risk.max_order_value, + }, + "current": { + "open_positions": open_count, + "daily_loss_check_passed": risk.check_daily_loss(cursor), + }, + } + except Exception as exc: + return {"error": str(exc)} + + +@router.post("/webhook") +async def webhook(request: Request): + try: + data = await request.json() + + if data.get("secret") != WEBHOOK_SECRET: + raise HTTPException(status_code=403, detail="Invalid secret") + + symbol = data["symbol"].upper() + strategy = data["strategy"] + action = strategy["order_action"].upper() + quantity = strategy["order_contracts"] + + if action not in ["BUY", "SELL"]: + raise HTTPException(status_code=400, detail=f"Invalid action: {action}") + + order_type = data.get("order_type", "MARKET").upper() + limit_price = data.get("limit_price") + take_profit = data.get("take_profit") + stop_loss = data.get("stop_loss") + + # Validate order parameters before any IB calls + if order_type == "LIMIT" and limit_price is None: + raise HTTPException(status_code=400, detail="limit_price required for LIMIT orders") + if order_type == "BRACKET" and ( + limit_price is None or take_profit is None or stop_loss is None + ): + raise HTTPException( + status_code=400, + detail="limit_price, take_profit, and stop_loss required for BRACKET orders", + ) + + ib = dependencies.get_ib() + cursor = dependencies.get_db_cursor() + conn = dependencies.get_db_conn() + lock = dependencies.get_db_lock() + + est_price = float(limit_price) if limit_price else 1.0 + risk_result = await risk.run_all_checks(ib, cursor, quantity, est_price) + if not risk_result["passed"]: + raise HTTPException( + status_code=429, + detail=f"Risk check failed: {', '.join(risk_result['failed_checks'])}", + ) + + # Qualify contract before placing order (C3) + raw_contract = Stock(symbol, "SMART", "USD") + qualified = await ib.qualifyContractsAsync(raw_contract) + if not qualified: + raise HTTPException( + status_code=400, detail=f"Could not qualify contract for {symbol}" + ) + contract = qualified[0] + + if order_type == "LIMIT": + order = LimitOrder(action, quantity, float(limit_price)) + trades_list = [ib.placeOrder(contract, order)] + elif order_type == "BRACKET": + bracket = ib.bracketOrder( + action, quantity, float(limit_price), float(take_profit), float(stop_loss) + ) + trades_list = [ib.placeOrder(contract, leg) for leg in bracket] + else: + order = MarketOrder(action, quantity) + trades_list = [ib.placeOrder(contract, order)] + + primary_trade = trades_list[0] + for _ in range(60): + await asyncio.sleep(0.5) + if primary_trade.isDone(): + break + + if not primary_trade.isDone() or not primary_trade.fills: + for t in trades_list: + try: + ib.cancelOrder(t.order) + except Exception: + pass + raise HTTPException(status_code=504, detail="Order did not fill in time") + + fill_price = primary_trade.fills[0].execution.price + log_trade(symbol, action, quantity, fill_price, cursor, conn, lock) + + return { + "status": "success", + "symbol": symbol, + "action": action, + "quantity": quantity, + "order_type": order_type, + "fill_price": fill_price, + } + + except HTTPException: + raise + except Exception as exc: + logger.error(f"Webhook error: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..1457b0f --- /dev/null +++ b/static/styles.css @@ -0,0 +1,493 @@ +/* Reset & Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f7fa; + line-height: 1.6; + color: #333; +} + +input, button, select { + font-size: 16px; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; +} + +button { + background: #3498db; + color: white; + cursor: pointer; + border: none; + transition: background 0.2s; +} + +button:hover { + background: #2980b9; +} + +button:active { + background: #21618c; +} + +/* Navigation */ +nav { + background: #2c3e50; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 20px; +} + +.nav-brand a { + color: white; + font-size: 1.5rem; + font-weight: bold; + text-decoration: none; + display: block; + padding: 15px 0; +} + +.nav-links { + list-style: none; + display: flex; + margin: 0; + padding: 0; +} + +.nav-links a { + color: #ecf0f1; + text-decoration: none; + padding: 15px 20px; + display: block; + transition: background 0.2s, border-bottom 0.2s; + border-bottom: 3px solid transparent; +} + +.nav-links a:hover { + background: #34495e; + border-bottom: 3px solid #3498db; +} + +.nav-links a.active { + background: #3498db; +} + +/* Layout */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Chart Page */ +.chart-section { + margin-top: 10px; +} + +#controls { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + padding: 20px; + display: flex; + align-items: center; + gap: 15px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +#controls .control-group { + display: flex; + align-items: center; + gap: 10px; +} + +#chart { + width: 100%; + height: 600px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: opacity 0.2s; +} + +#chart.loading { + opacity: 0.6; +} + +.loading-indicator { + display: none; + color: #7f8c8d; + font-size: 0.9rem; +} + +.loading-indicator.show { + display: block; +} + +/* Scanner Page */ +.scanner-container { + max-width: 1400px; + margin: 0 auto; + padding-top: 10px; +} + +.scanner-header { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + padding: 30px; + margin-bottom: 25px; +} + +.scanner-title { + font-size: 2rem; + font-weight: 600; + color: #2c3e50; + margin-bottom: 20px; +} + +.scanner-controls { + display: flex; + flex-wrap: wrap; + gap: 25px; + align-items: flex-end; +} + +.scanner-controls .control-group { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 200px; +} + +.scanner-controls label { + font-size: 0.85rem; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.scanner-controls input, +.scanner-controls select { + width: 100%; +} + +.scan-button { + min-width: 140px; + padding: 10px 20px; + font-weight: 600; +} + +.scan-button:disabled { + background: #ccc; + cursor: not-allowed; +} + +.export-button { + background: #27ae60; +} + +.export-button:hover { + background: #219150; +} + +.results-container { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.results-header { + background: #1a1a1a; + color: white; + padding: 20px 25px; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + font-size: 1rem; +} + +.results-count { + background: white; + color: #1a1a1a; + border-radius: 20px; + padding: 4px 14px; + font-size: 0.85rem; + font-weight: 600; +} + +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(750px, 1fr)); +} + +.result-card { + padding: 30px; + cursor: pointer; + display: flex; + gap: 25px; + align-items: flex-start; + border-bottom: 1px solid #f0f0f0; + transition: background 0.15s, transform 0.15s; +} + +.result-card:hover { + background: #f8f9ff; + transform: translateY(-1px); +} + +.chart-thumbnail { + width: 640px; + height: auto; + border-radius: 8px; + object-fit: contain; + flex-shrink: 0; +} + +.chart-placeholder { + width: 640px; + height: 200px; + border-radius: 8px; + background: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: bold; + color: #aaa; + flex-shrink: 0; +} + +.symbol-details { + display: flex; + flex-direction: column; + gap: 6px; +} + +.symbol-name { + font-weight: 700; + font-size: 1.2rem; + color: #2c3e50; +} + +.symbol-company { + color: #555; + font-size: 0.95rem; +} + +.symbol-exchange { + color: #888; + font-size: 0.85rem; +} + +.loading-state { + padding: 60px; + text-align: center; + color: #7f8c8d; +} + +.scanner-spinner { + width: 40px; + height: 40px; + border: 4px solid #f0f0f0; + border-top: 4px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Trade Log Page */ +h1 { + margin: 20px 0 16px; + font-size: 1.8rem; + color: #2c3e50; +} + +.dashboard-container { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 15px; + text-align: left; + border-bottom: 1px solid #eee; +} + +th { + background: #f8f9fa; + font-weight: 600; + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 0.05em; + color: #555; +} + +tr:hover { + background: #f5f7fa; +} + +.action-tag { + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; +} + +.action-tag.buy { + color: #27ae60; + background: #e9f7ef; +} + +.action-tag.sell { + color: #e74c3c; + background: #fdedec; +} + +.symbol-link { + color: #3498db; + text-decoration: none; + font-weight: 600; +} + +.symbol-link:hover { + text-decoration: underline; +} + +.price-cell { + font-family: monospace; + font-weight: 600; +} + +.empty-state { + text-align: center; + padding: 40px; + color: #7f8c8d; +} + +/* Portfolio Page */ +.portfolio-summary { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 20px; + margin-top: 10px; +} + +.summary-card { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + padding: 24px; + text-align: center; +} + +.card-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #7f8c8d; + font-weight: 600; + margin-bottom: 8px; +} + +.card-value { + font-size: 1.6rem; + font-weight: 700; + color: #2c3e50; + font-family: monospace; +} + +.positive { + color: #27ae60 !important; +} + +.negative { + color: #e74c3c !important; +} + +/* Responsive */ +@media (max-width: 768px) { + .nav-container { + flex-direction: column; + padding: 0; + } + + .nav-links { + width: 100%; + flex-direction: column; + } + + .nav-links a { + padding: 12px 20px; + } + + #controls { + flex-direction: column; + align-items: stretch; + } + + #controls .control-group { + flex-direction: column; + } + + #chart { + height: 400px; + } + + .scanner-controls { + flex-direction: column; + } + + .scanner-controls .control-group { + min-width: 100%; + } + + .results-grid { + grid-template-columns: 1fr; + } + + .result-card { + flex-direction: column; + align-items: center; + text-align: center; + } + + .chart-thumbnail { + width: 90%; + max-width: 480px; + } + + .chart-placeholder { + width: 90%; + max-width: 480px; + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c09d0e4 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,93 @@ +{% extends "layout.html" %} + +{% block title %}Real-Time Chart — IBKR Dashboard{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+
+ + +
+
Loading...
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..f38a261 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,29 @@ + + + + + + {% block title %}IBKR Trading Dashboard{% endblock %} + + {% block extra_head %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ {% block extra_scripts %}{% endblock %} + + diff --git a/templates/portfolio.html b/templates/portfolio.html new file mode 100644 index 0000000..6ff9218 --- /dev/null +++ b/templates/portfolio.html @@ -0,0 +1,108 @@ +{% extends "layout.html" %} + +{% block title %}Portfolio — IBKR Dashboard{% endblock %} + +{% block content %} +

Portfolio

+ +
+
+
Net Liquidation
+
+
+
+
Total Cash
+
+
+
+
Unrealized P&L
+
+
+
+
Realized P&L
+
+
+
+ +
+ + + + + + + + + + + + +
SymbolPositionAvg CostChart
Loading positions...
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/scanner.html b/templates/scanner.html new file mode 100644 index 0000000..5575dab --- /dev/null +++ b/templates/scanner.html @@ -0,0 +1,215 @@ +{% extends "layout.html" %} + +{% block title %}Market Scanner — IBKR Dashboard{% endblock %} + +{% block content %} +
+
+

Market Scanner

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ Scanner Results + +
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/tradelog.html b/templates/tradelog.html new file mode 100644 index 0000000..c19572b --- /dev/null +++ b/templates/tradelog.html @@ -0,0 +1,41 @@ +{% extends "layout.html" %} + +{% block title %}Trade Log — IBKR Dashboard{% endblock %} + +{% block content %} +

Trade Log

+
+ {% if trades %} + + + + + + + + + + + + {% for trade in trades %} + + + + + + + + {% endfor %} + +
Time (UTC)SymbolActionQuantityFill Price
{{ trade.timestamp }} + {{ trade.symbol }} + + {{ trade.action }} + {{ trade.quantity }}${{ '%.2f'|format(trade.price) }}
+ {% else %} +
+

No trades have been recorded yet. Executed trades from webhooks will appear here.

+
+ {% endif %} +
+{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..1b39fe2 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,322 @@ +""" +Integration test suite — pre-Docker validation. + +Run all tests (mocked IB): + 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 +""" +import os +import sqlite3 +import sys +import threading +import types +import unittest.mock as mock +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +SKIP_LIVE = os.getenv("SKIP_LIVE_TESTS", "0") == "1" + +# ── Minimal ib_async stubs (used when real ib_async not available) ──────────── +def _make_stub(): + mod = types.ModuleType("ib_async") + + class _FakeIB: + def positions(self): + return [] + + async def reqAccountSummaryAsync(self): + return [] + + def placeOrder(self, *a, **kw): + return mock.MagicMock(isDone=lambda: False, fills=[]) + + def cancelOrder(self, *a, **kw): + pass + + def bracketOrder(self, *a, **kw): + return [mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] + + mod.IB = _FakeIB + mod.Stock = mock.MagicMock + mod.MarketOrder = mock.MagicMock + mod.LimitOrder = mock.MagicMock + mod.ScannerSubscription = mock.MagicMock + return mod, _FakeIB + + +if "ib_async" not in sys.modules: + _ib_mod, _FakeIBClass = _make_stub() + sys.modules["ib_async"] = _ib_mod +else: + _FakeIBClass = None + +if "dotenv" not in sys.modules: + sys.modules["dotenv"] = types.SimpleNamespace(load_dotenv=lambda: None) + +import dependencies # noqa: E402 + +_test_conn = sqlite3.connect(":memory:", check_same_thread=False) +_test_cursor = _test_conn.cursor() +_test_cursor.execute(""" + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + symbol TEXT NOT NULL, + action TEXT NOT NULL, + quantity INTEGER NOT NULL, + price REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +""") +_test_conn.commit() +_test_lock = threading.Lock() + +_ib_module = sys.modules["ib_async"] +_fake_ib = _ib_module.IB() +dependencies.setup_dependencies(_fake_ib, _test_conn, _test_cursor, _test_lock) + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from routers import charts, health, scanner, trades, portfolio # noqa: E402 + +_app = FastAPI() +_app.include_router(health.router) +_app.include_router(charts.router) +_app.include_router(scanner.router) +_app.include_router(trades.router) +_app.include_router(portfolio.router) + +client = TestClient(_app, raise_server_exceptions=False) + +WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your_super_secret_string_123") +BASE = Path(__file__).parent.parent + + +# ── IBKR Connection Tests ───────────────────────────────────────────────────── + +class TestIBKRConnection: + + def test_ibkr_host_env_set(self): + host = os.getenv("IBKR_HOST", "127.0.0.1") + assert host and host.strip(), "IBKR_HOST env var must not be empty" + + 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}" + ) + + @pytest.mark.skipif(SKIP_LIVE, reason="SKIP_LIVE_TESTS=1") + def test_ibkr_connection_live(self): + import asyncio + + try: + from ib_async import IB # type: ignore + except ImportError: + pytest.skip("ib_async not installed") + + ib = IB() + host = os.getenv("IBKR_HOST", "127.0.0.1") + port = int(os.getenv("IBKR_PORT", "4002")) + client_id = int(os.getenv("IBKR_CLIENT_ID", "99")) + + async def _connect(): + await asyncio.wait_for( + ib.connectAsync(host, port, clientId=client_id), + timeout=10.0, + ) + + try: + asyncio.run(_connect()) + except asyncio.TimeoutError: + pytest.fail(f"Timed out connecting to IB Gateway at {host}:{port}") + except Exception as exc: + pytest.fail(f"Connection failed: {exc}") + + assert ib.isConnected(), "IB Gateway connection not established" + ib.disconnect() + + @pytest.mark.skipif(SKIP_LIVE, reason="SKIP_LIVE_TESTS=1") + def test_history_returns_data(self): + r = client.get("/history?symbol=AAPL") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) and len(data) > 0, ( + "Expected non-empty history list for AAPL" + ) + + +# ── All Endpoint Tests ──────────────────────────────────────────────────────── + +class TestAllEndpoints: + + def test_home_200(self): + r = client.get("/") + assert r.status_code == 200 + + def test_scanner_page_200(self): + r = client.get("/scanner") + assert r.status_code == 200 + + def test_tradelog_200(self): + r = client.get("/tradelog") + assert r.status_code == 200 + + def test_portfolio_200(self): + r = client.get("/portfolio") + assert r.status_code == 200 + + def test_risk_status_200(self): + r = client.get("/risk/status") + assert r.status_code == 200 + + def test_subscribe_returns_ok(self): + r = client.post("/subscribe", json={"symbol": "AAPL"}) + assert r.status_code == 200 + body = r.json() + assert "status" in body, f"Expected 'status' key in response: {body}" + + def test_webhook_wrong_secret_403(self): + r = client.post( + "/webhook", + json={ + "secret": "totally_wrong_secret_xyz", + "symbol": "AAPL", + "strategy": {"order_action": "BUY", "order_contracts": 1}, + }, + ) + assert r.status_code == 403 + + def test_webhook_limit_missing_price_400(self): + r = client.post( + "/webhook", + json={ + "secret": WEBHOOK_SECRET, + "symbol": "AAPL", + "order_type": "LIMIT", + "strategy": {"order_action": "BUY", "order_contracts": 1}, + }, + ) + assert r.status_code == 400 + + def test_scanner_export_csv(self): + r = client.post("/scanner/export", json={"scan_type": "HOT_BY_VOLUME"}) + assert r.status_code in (200, 500), ( + f"Expected 200 (CSV or error JSON) or 500, got {r.status_code}" + ) + if r.status_code == 200: + ct = r.headers.get("content-type", "") + if "csv" not in ct.lower(): + body = r.json() + assert body.get("status") == "error", ( + f"Expected CSV or error JSON, got content-type={ct}: {r.text[:120]}" + ) + + +# ── Docker Readiness Tests ──────────────────────────────────────────────────── + +class TestDockerReadiness: + + def test_env_example_exists(self): + assert (BASE / ".env.example").exists(), ( + ".env.example not found — create from .env.example template" + ) + + def test_dockerfile_exists(self): + assert (BASE / "Dockerfile").exists(), "Dockerfile not found" + + def test_docker_compose_exists(self): + assert (BASE / "docker-compose.yml").exists(), "docker-compose.yml not found" + + def test_verify_script_exists(self): + assert (BASE / "verify_docker.sh").exists(), ( + "verify_docker.sh not found" + ) + + def test_nginx_conf_removed(self): + assert not (BASE / "nginx.conf").exists(), ( + "nginx.conf should be removed — Traefik is now the reverse proxy" + ) + + def test_env_example_has_tws_userid(self): + env_example = (BASE / ".env.example").read_text() + assert "TWS_USERID" in env_example, ".env.example must define TWS_USERID" + + def test_env_example_has_tws_password(self): + env_example = (BASE / ".env.example").read_text() + assert "TWS_PASSWORD" in env_example, ".env.example must define TWS_PASSWORD" + + def test_env_example_has_traefik_vars(self): + env_example = (BASE / ".env.example").read_text() + assert "TRAEFIK_NETWORK" in env_example, ".env.example must define TRAEFIK_NETWORK" + assert "TRAEFIK_HOST" in env_example, ".env.example must define TRAEFIK_HOST" + + def test_docker_compose_uses_gnzsnz_image(self): + compose = (BASE / "docker-compose.yml").read_text() + assert "gnzsnz/ib-gateway" in compose, ( + "docker-compose.yml must use ghcr.io/gnzsnz/ib-gateway image" + ) + + def test_docker_compose_has_healthcheck(self): + compose = (BASE / "docker-compose.yml").read_text() + assert "healthcheck" in compose, ( + "docker-compose.yml must define healthchecks" + ) + + def test_docker_compose_has_traefik_labels(self): + compose = (BASE / "docker-compose.yml").read_text() + assert "traefik.enable=true" in compose, ( + "docker-compose.yml must have Traefik labels" + ) + assert "flushinterval=-1" in compose, ( + "docker-compose.yml must set SSE flush interval to -1 for Traefik" + ) + + def test_dockerfile_has_curl(self): + dockerfile = (BASE / "Dockerfile").read_text() + assert "curl" in dockerfile, ( + "Dockerfile must install curl (required for healthcheck)" + ) + + def test_requirements_has_httpx(self): + reqs = (BASE / "requirements.txt").read_text() + assert "httpx" in reqs, ( + "requirements.txt must include httpx (FastAPI TestClient dependency)" + ) + + +# ── Health Endpoint Tests ───────────────────────────────────────────────────── + +class TestHealthEndpoint: + + def test_health_200(self): + r = client.get("/health") + assert r.status_code == 200 + + def test_health_returns_status_field(self): + r = client.get("/health") + body = r.json() + assert "status" in body, f"Expected 'status' key in /health response: {body}" + + def test_health_returns_ib_connected(self): + r = client.get("/health") + body = r.json() + assert "ib_connected" in body + + def test_health_returns_db_ok(self): + r = client.get("/health") + body = r.json() + assert "db_ok" in body + + def test_health_returns_version(self): + r = client.get("/health") + body = r.json() + assert body.get("version") == "3.0" diff --git a/tests/test_phase2.py b/tests/test_phase2.py new file mode 100644 index 0000000..542a1b0 --- /dev/null +++ b/tests/test_phase2.py @@ -0,0 +1,224 @@ +""" +Phase 2 test suite — uses TestClient with mocked IB and DB dependencies. +Run: pytest tests/test_phase2.py -v +""" +import sqlite3 +import sys +import threading +import types +import unittest.mock as mock +from pathlib import Path + +import pytest + +# Ensure project root is on sys.path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# ── Minimal ib_async stubs so the module imports without a real installation ── +def _make_ib_async_stub(): + mod = types.ModuleType("ib_async") + + class _FakeIB: + def positions(self): + return [] + async def reqAccountSummaryAsync(self): + return [] + def placeOrder(self, *a, **kw): + return mock.MagicMock(isDone=lambda: False, fills=[]) + def cancelOrder(self, *a, **kw): + pass + + mod.IB = _FakeIB + mod.Stock = mock.MagicMock + mod.MarketOrder = mock.MagicMock + mod.LimitOrder = mock.MagicMock + mod.ScannerSubscription = mock.MagicMock + + def _bracket_order(action, qty, lp, tp, sl): + return [mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] + + mod.BracketOrder = mock.MagicMock + return mod, _FakeIB, _bracket_order + + +_ib_mod, _FakeIB, _bracket_fn = _make_ib_async_stub() +sys.modules.setdefault("ib_async", _ib_mod) + +# ── Patch dotenv so .env is not required during tests ── +sys.modules.setdefault( + "dotenv", + types.SimpleNamespace(load_dotenv=lambda: None), +) + +# ── In-memory SQLite DB for tests ── +_test_conn = sqlite3.connect(":memory:", check_same_thread=False) +_test_cursor = _test_conn.cursor() +_test_cursor.execute(""" + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + symbol TEXT NOT NULL, + action TEXT NOT NULL, + quantity INTEGER NOT NULL, + price REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +""") +_test_conn.commit() +_test_lock = threading.Lock() + +import dependencies # noqa: E402 — after stubs are in sys.modules + +_fake_ib = _FakeIB() +dependencies.setup_dependencies(_fake_ib, _test_conn, _test_cursor, _test_lock) + +# ── Import app AFTER dependencies are wired ── +from fastapi.testclient import TestClient # noqa: E402 + +# Bypass lifespan for tests +from fastapi import FastAPI # noqa: E402 +from routers import charts, scanner, trades, portfolio # noqa: E402 + +_app = FastAPI() +_app.include_router(charts.router) +_app.include_router(scanner.router) +_app.include_router(trades.router) +_app.include_router(portfolio.router) + +client = TestClient(_app, raise_server_exceptions=False) + +WRONG_SECRET = "wrong_secret" +RIGHT_SECRET = "your_super_secret_string_123" + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +class TestHomePage: + def test_home_200(self): + r = client.get("/") + assert r.status_code == 200 + + def test_home_has_chart(self): + r = client.get("/") + assert "chart" in r.text.lower() + + +class TestPortfolio: + def test_portfolio_page_200(self): + r = client.get("/portfolio") + assert r.status_code == 200 + + def test_portfolio_data_is_list(self): + r = client.get("/portfolio/data") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + def test_portfolio_pnl_returns_dict(self): + r = client.get("/portfolio/pnl") + assert r.status_code == 200 + assert isinstance(r.json(), dict) + + +class TestRiskStatus: + def test_risk_status_200(self): + r = client.get("/risk/status") + assert r.status_code == 200 + + def test_risk_status_has_limits(self): + r = client.get("/risk/status") + body = r.json() + assert "limits" in body + assert "max_daily_loss" in body["limits"] + assert "max_positions" in body["limits"] + assert "max_order_value" in body["limits"] + + +class TestWebhookAuth: + def test_wrong_secret_403(self): + r = client.post( + "/webhook", + json={ + "secret": WRONG_SECRET, + "symbol": "AAPL", + "strategy": {"order_action": "BUY", "order_contracts": 1}, + }, + ) + assert r.status_code == 403 + + def test_invalid_action_400(self): + r = client.post( + "/webhook", + json={ + "secret": RIGHT_SECRET, + "symbol": "AAPL", + "strategy": {"order_action": "HOLD", "order_contracts": 1}, + }, + ) + assert r.status_code == 400 + + +class TestAdvancedOrders: + def test_limit_order_missing_price_400(self): + r = client.post( + "/webhook", + json={ + "secret": RIGHT_SECRET, + "symbol": "AAPL", + "order_type": "LIMIT", + "strategy": {"order_action": "BUY", "order_contracts": 1}, + }, + ) + assert r.status_code == 400 + + def test_bracket_order_missing_fields_400(self): + r = client.post( + "/webhook", + json={ + "secret": RIGHT_SECRET, + "symbol": "AAPL", + "order_type": "BRACKET", + "limit_price": 150.0, + "strategy": {"order_action": "BUY", "order_contracts": 1}, + }, + ) + assert r.status_code == 400 + + +class TestTradeLog: + def test_tradelog_200(self): + r = client.get("/tradelog") + assert r.status_code == 200 + + def test_tradelog_shows_table(self): + r = client.get("/tradelog") + assert "Trade Log" in r.text + + +class TestScanner: + def test_scanner_page_200(self): + r = client.get("/scanner") + assert r.status_code == 200 + + def test_scanner_export_requires_ib(self): + # Without a real IB connection the export endpoint still returns a response + r = client.post("/scanner/export", json={"scan_type": "HOT_BY_VOLUME"}) + # Either a CSV or an error JSON — both are valid without IB + assert r.status_code in (200, 500) + + +class TestRiskManagerUnit: + def test_order_value_passes(self): + from risk_manager import RiskManager + rm = RiskManager() + assert rm.check_order_value(1, 100.0) is True + + def test_order_value_fails(self): + from risk_manager import RiskManager + rm = RiskManager() + rm.max_order_value = 100.0 + assert rm.check_order_value(10, 200.0) is False + + def test_position_count_passes(self): + from risk_manager import RiskManager + rm = RiskManager() + assert rm.check_position_count(_fake_ib) is True diff --git a/verify_docker.sh b/verify_docker.sh new file mode 100755 index 0000000..de6c6bd --- /dev/null +++ b/verify_docker.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# verify_docker.sh — IBKR Dashboard Docker Deployment Verification +# 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) + +set -uo pipefail + +BASE="http://localhost:8000" +PASS=0 +FAIL=0 + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +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"; } + +check_body() { + local label="$1" result="$2" expected="$3" + if echo "$result" | grep -q "$expected"; then + pass "$label" + else + fail "$label (got: $(echo "$result" | head -c 120))" + fi +} + +check_status() { + local label="$1" url="$2" expected_code="$3" + local code + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000") + if [ "$code" = "$expected_code" ]; then + pass "$label" + else + fail "$label (HTTP $code, expected $expected_code)" + fi +} + +echo "" +echo "════════════════════════════════════════════════════" +echo " IBKR Dashboard — Docker Deployment Verification" +echo " Reverse proxy: Traefik | Testing via port 8000" +echo "════════════════════════════════════════════════════" +echo "" + +# ── Container status ────────────────────────────────────────────────────────── +echo "── Container Status ──────────────────────────────" +for svc in ib-gateway ibkr-dashboard; do + DOCKER_STATUS=$(docker ps --format "{{.Names}} {{.Status}}" 2>/dev/null \ + | grep "$svc" | grep -i "up" || echo "") + if [ -n "$DOCKER_STATUS" ]; then + pass "Container $svc is Up" + else + fail "Container $svc is Up" + fi +done + +echo "" +echo "── Port Checks ───────────────────────────────────" + +if nc -zv 127.0.0.1 4002 2>/dev/null; then + pass "Port 4002 open (IB Gateway paper trading)" +else + fail "Port 4002 open (IB Gateway paper trading)" +fi + +if nc -zv 127.0.0.1 8000 2>/dev/null; then + pass "Port 8000 open (FastAPI direct)" +else + fail "Port 8000 open (FastAPI direct)" +fi + +echo "" +echo "── HTTP Endpoint Checks (direct → port 8000) ────" + +check_status "GET /health → 200" "$BASE/health" "200" + +R=$(curl -s --max-time 10 "$BASE/health" 2>/dev/null || echo "{}") +check_body "GET /health → ib_connected field present" "$R" "ib_connected" +check_body "GET /health → db_ok field present" "$R" "db_ok" + +check_status "GET / → 200" "$BASE/" "200" + +R=$(curl -s --max-time 20 "$BASE/history?symbol=AAPL" 2>/dev/null || echo "[]") +if echo "$R" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + sys.exit(0 if isinstance(d, list) and len(d) > 0 else 1) +except Exception: + sys.exit(1) +" 2>/dev/null; then + pass "GET /history?symbol=AAPL → non-empty list" +else + fail "GET /history?symbol=AAPL → non-empty list (got: $(echo "$R" | head -c 80))" +fi + +check_status "GET /portfolio/data → 200" "$BASE/portfolio/data" "200" + +R=$(curl -s --max-time 15 "$BASE/portfolio/pnl" 2>/dev/null || echo "{}") +check_body "GET /portfolio/pnl → contains NetLiquidation" "$R" "NetLiquidation" + +R=$(curl -s --max-time 10 "$BASE/risk/status" 2>/dev/null || echo "{}") +check_body "GET /risk/status → contains max_daily_loss" "$R" "max_daily_loss" + +check_status "GET /scanner → 200" "$BASE/scanner" "200" +check_status "GET /tradelog → 200" "$BASE/tradelog" "200" + +R=$(curl -s --max-time 15 -X POST "$BASE/subscribe" \ + -H "Content-Type: application/json" \ + -d '{"symbol":"SPY"}' 2>/dev/null || echo "{}") +check_body "POST /subscribe {symbol:SPY} → contains status" "$R" '"status"' + +CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ + -X POST "$BASE/webhook" \ + -H "Content-Type: application/json" \ + -d '{"secret":"wrong_secret_xyz","symbol":"AAPL","strategy":{"order_action":"BUY","order_contracts":1}}' \ + 2>/dev/null || echo "000") +if [ "$CODE" = "403" ]; then + pass "POST /webhook wrong secret → 403" +else + fail "POST /webhook wrong secret → 403 (got: HTTP $CODE)" +fi + +echo "" +echo "── IBKR Connection Log Check ─────────────────────" +if docker 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" +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 + pass "pytest inside container" +else + fail "pytest inside container" +fi + +TOTAL=$((PASS + FAIL)) +echo "" +echo "════════════════════════════════════════════════════" +printf " Summary: ${GREEN}PASS %d${NC} / %d ${RED}FAIL %d${NC} / %d\n" \ + "$PASS" "$TOTAL" "$FAIL" "$TOTAL" +echo "════════════════════════════════════════════════════" + +if [ "$FAIL" -eq 0 ]; then + echo -e "${GREEN}ALL CHECKS PASSED — deployment is healthy.${NC}" + exit 0 +else + echo -e "${RED}${FAIL} CHECK(S) FAILED — review above.${NC}" + exit 1 +fi diff --git a/verify_phase2.sh b/verify_phase2.sh new file mode 100755 index 0000000..e4a1719 --- /dev/null +++ b/verify_phase2.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# verify_phase2.sh — Phase 2 endpoint smoke tests +# Usage: chmod +x verify_phase2.sh && ./verify_phase2.sh +# Requires app running on localhost:8000 + +BASE="http://localhost:8000" +PASS=0 +FAIL=0 +SECRET="your_super_secret_string_123" + +check() { + local label="$1" + local result="$2" + local expected="$3" + if echo "$result" | grep -q "$expected"; then + echo "[PASS] $label" + PASS=$((PASS+1)) + else + echo "[FAIL] $label (got: $(echo "$result" | head -c 120))" + FAIL=$((FAIL+1)) + fi +} + +check_status() { + local label="$1" + local url="$2" + local expected_code="$3" + local code + code=$(curl -s -o /dev/null -w "%{http_code}" "$url") + if [ "$code" = "$expected_code" ]; then + echo "[PASS] $label" + PASS=$((PASS+1)) + else + echo "[FAIL] $label (HTTP $code, expected $expected_code)" + FAIL=$((FAIL+1)) + fi +} + +# 1 — Ana sayfa +check_status "Ana sayfa HTTP 200" "$BASE/" "200" + +# 2 — Tarihsel veri (empty list or array without crashing) +R=$(curl -s "$BASE/history?symbol=AAPL") +check "Tarihsel veri dolu" "$R" "\[" + +# 3 — Subscribe +R=$(curl -s -X POST "$BASE/subscribe" \ + -H "Content-Type: application/json" \ + -d '{"symbol":"AAPL"}') +check "Subscribe success" "$R" '"status"' + +# 4 — Scanner page +check_status "Scanner sayfasi HTTP 200" "$BASE/scanner" "200" + +# 5 — Scanner POST returns ok or error (not a crash) +R=$(curl -s -X POST "$BASE/scanner" \ + -H "Content-Type: application/json" \ + -d '{"scan_type":"HOT_BY_VOLUME","min_price":5,"min_volume":100000}') +check "Scanner status ok" "$R" '"status"' + +# 6 — Scanner CSV Content-Type +CT=$(curl -s -o /dev/null -w "%{content_type}" -X POST "$BASE/scanner/export" \ + -H "Content-Type: application/json" \ + -d '{"scan_type":"HOT_BY_VOLUME"}') +if echo "$CT" | grep -qi "csv\|json"; then + echo "[PASS] Scanner CSV Content-Type text/csv" + PASS=$((PASS+1)) +else + echo "[FAIL] Scanner CSV Content-Type text/csv (got: $CT)" + FAIL=$((FAIL+1)) +fi + +# 7 — Portfolio page +check_status "Portfolio sayfa HTTP 200" "$BASE/portfolio" "200" + +# 8 — Portfolio data is list +R=$(curl -s "$BASE/portfolio/data") +if echo "$R" | grep -qE "^\[|\"error\""; then + echo "[PASS] Portfolio data is list" + PASS=$((PASS+1)) +else + echo "[FAIL] Portfolio data is list (got: $(echo "$R" | head -c 80))" + FAIL=$((FAIL+1)) +fi + +# 9 — Risk status endpoint +R=$(curl -s "$BASE/risk/status") +check "Risk status endpoint" "$R" '"limits"' + +# 10 — Webhook wrong secret → 403 +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/webhook" \ + -H "Content-Type: application/json" \ + -d '{"secret":"yanlis_secret","symbol":"AAPL","strategy":{"order_action":"BUY","order_contracts":1}}') +if [ "$CODE" = "403" ]; then + echo "[PASS] Webhook yanlis secret → 403" + PASS=$((PASS+1)) +else + echo "[FAIL] Webhook yanlis secret → 403 (got: HTTP $CODE)" + FAIL=$((FAIL+1)) +fi + +# 11 — Webhook invalid action → 400 +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/webhook" \ + -H "Content-Type: application/json" \ + -d "{\"secret\":\"$SECRET\",\"symbol\":\"AAPL\",\"strategy\":{\"order_action\":\"HOLD\",\"order_contracts\":1}}") +if [ "$CODE" = "400" ]; then + echo "[PASS] Webhook gecersiz action → 400" + PASS=$((PASS+1)) +else + echo "[FAIL] Webhook gecersiz action → 400 (got: HTTP $CODE)" + FAIL=$((FAIL+1)) +fi + +# 12 — Limit order missing price → 400 +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/webhook" \ + -H "Content-Type: application/json" \ + -d "{\"secret\":\"$SECRET\",\"symbol\":\"AAPL\",\"order_type\":\"LIMIT\",\"strategy\":{\"order_action\":\"BUY\",\"order_contracts\":1}}") +if [ "$CODE" = "400" ]; then + echo "[PASS] Limit order eksik fiyat → 400" + PASS=$((PASS+1)) +else + echo "[FAIL] Limit order eksik fiyat → 400 (got: HTTP $CODE)" + FAIL=$((FAIL+1)) +fi + +# 13 — Trade log page +check_status "Trade log HTTP 200" "$BASE/tradelog" "200" + +# ── pytest ──────────────────────────────────────────────────────────────────── +echo "" +echo "Running pytest tests/ ..." +if command -v pytest &>/dev/null; then + pytest tests/ -q --tb=short 2>&1 + PYTEST_EXIT=$? +else + echo "[SKIP] pytest not found — install with: pip install pytest httpx" + PYTEST_EXIT=0 +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +TOTAL=$((PASS+FAIL)) +echo "" +echo "======= Curl checks: PASS $PASS / $TOTAL =======" +if [ "$PYTEST_EXIT" -eq 0 ]; then + echo "======= pytest: all passed =======" +else + echo "======= pytest: FAILURES detected =======" +fi +echo "" + +if [ "$FAIL" -eq 0 ] && [ "$PYTEST_EXIT" -eq 0 ]; then + echo "PASS: $PASS / $TOTAL — Phase 2 deployment hazir." + exit 0 +else + echo "FAIL: $FAIL curl failures, pytest exit=$PYTEST_EXIT" + exit 1 +fi