initial commit

This commit is contained in:
coddard
2026-05-18 00:31:08 +03:00
commit 0096c8819b
26 changed files with 2851 additions and 0 deletions
+43
View File
@@ -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)
+12
View File
@@ -0,0 +1,12 @@
.env
trades.db
__pycache__/
**/__pycache__/
*.pyc
*.pyo
venv/
.venv/
*.egg-info/
dist/
build/
.DS_Store
+17
View File
@@ -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"]
+59
View File
@@ -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)
+73
View File
@@ -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}
+26
View File
@@ -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
+153
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
ib_async
sse-starlette
jinja2
python-dotenv
httpx
pytest
+79
View File
@@ -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}
View File
+175
View File
@@ -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())
+37
View File
@@ -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",
}
)
+48
View File
@@ -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)}
+109
View File
@@ -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)}
+171
View File
@@ -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))
+493
View File
@@ -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;
}
}
+93
View File
@@ -0,0 +1,93 @@
{% extends "layout.html" %}
{% block title %}Real-Time Chart — IBKR Dashboard{% endblock %}
{% block extra_head %}
<script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>
{% endblock %}
{% block content %}
<div class="chart-section">
<div id="controls">
<div class="control-group">
<input type="text" id="symbolInput" placeholder="Enter symbol (e.g. AAPL)" value="{{ symbol }}">
<button onclick="subscribeSymbol()">Load Chart</button>
</div>
<div class="loading-indicator" id="loadingIndicator">Loading...</div>
</div>
<div id="chart"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
const chart = LightweightCharts.createChart(document.getElementById('chart'), {
width: document.getElementById('chart').clientWidth,
height: 600,
layout: { background: { color: '#ffffff' }, textColor: '#333' },
grid: { vertLines: { color: '#f0f0f0' }, horzLines: { color: '#f0f0f0' } },
timeScale: { timeVisible: true, secondsVisible: false }
});
const series = chart.addCandlestickSeries();
let eventSource = null;
function cleanupConnections() {
if (eventSource) {
console.log('Closing EventSource connection');
eventSource.close();
eventSource = null;
}
}
async function subscribeSymbol() {
cleanupConnections();
const symbol = document.getElementById("symbolInput").value.toUpperCase();
document.getElementById("loadingIndicator").classList.add("show");
document.getElementById("chart").classList.add("loading");
const historyResponse = await fetch(`/history?symbol=${symbol}`);
const historicalData = await historyResponse.json();
series.setData(historicalData);
await fetch("/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol })
});
eventSource = new EventSource("/stream");
eventSource.addEventListener("candle", (event) => {
const candleData = JSON.parse(event.data);
series.update(candleData);
});
eventSource.addEventListener("error", (event) => {
console.error('SSE Error:', event);
});
eventSource.addEventListener("open", () => {
console.log('SSE Connection Opened');
document.getElementById("loadingIndicator").classList.remove("show");
document.getElementById("chart").classList.remove("loading");
});
}
window.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const symbol = urlParams.get('symbol');
if (symbol) {
document.getElementById("symbolInput").value = symbol;
subscribeSymbol();
}
});
window.addEventListener('resize', () => {
chart.applyOptions({ width: document.getElementById('chart').clientWidth });
});
window.addEventListener('beforeunload', cleanupConnections);
window.addEventListener('pagehide', cleanupConnections);
</script>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}IBKR Trading Dashboard{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css">
{% block extra_head %}{% endblock %}
</head>
<body>
<nav>
<div class="nav-container">
<div class="nav-brand">
<a href="/">IBKR Dashboard</a>
</div>
<ul class="nav-links">
<li><a href="/">Real-Time Chart</a></li>
<li><a href="/scanner">Market Scanner</a></li>
<li><a href="/tradelog">Trade Log</a></li>
<li><a href="/portfolio">Portfolio</a></li>
</ul>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
{% extends "layout.html" %}
{% block title %}Portfolio — IBKR Dashboard{% endblock %}
{% block content %}
<h1>Portfolio</h1>
<div class="portfolio-summary" id="pnlCards">
<div class="summary-card">
<div class="card-label">Net Liquidation</div>
<div class="card-value" id="netLiq"></div>
</div>
<div class="summary-card">
<div class="card-label">Total Cash</div>
<div class="card-value" id="totalCash"></div>
</div>
<div class="summary-card">
<div class="card-label">Unrealized P&amp;L</div>
<div class="card-value" id="unrealizedPnl"></div>
</div>
<div class="summary-card">
<div class="card-label">Realized P&amp;L</div>
<div class="card-value" id="realizedPnl"></div>
</div>
</div>
<div class="dashboard-container" style="margin-top: 24px;">
<table>
<thead>
<tr>
<th>Symbol</th>
<th>Position</th>
<th>Avg Cost</th>
<th>Chart</th>
</tr>
</thead>
<tbody id="positionsBody">
<tr><td colspan="4" class="empty-state">Loading positions...</td></tr>
</tbody>
</table>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function fmt(val) {
if (val === undefined || val === null) return '—';
return '$' + parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function colorClass(val) {
const n = parseFloat(val);
if (isNaN(n)) return '';
return n >= 0 ? 'positive' : 'negative';
}
async function loadPnl() {
try {
const res = await fetch('/portfolio/pnl');
const d = await res.json();
document.getElementById('netLiq').textContent = fmt(d.NetLiquidation);
document.getElementById('totalCash').textContent = fmt(d.TotalCashValue);
const upEl = document.getElementById('unrealizedPnl');
upEl.textContent = fmt(d.UnrealizedPnL);
upEl.className = 'card-value ' + colorClass(d.UnrealizedPnL);
const rpEl = document.getElementById('realizedPnl');
rpEl.textContent = fmt(d.RealizedPnL);
rpEl.className = 'card-value ' + colorClass(d.RealizedPnL);
} catch (e) {
console.error('PnL fetch error:', e);
}
}
async function loadPositions() {
try {
const res = await fetch('/portfolio/data');
const data = await res.json();
const tbody = document.getElementById('positionsBody');
if (!Array.isArray(data) || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No open positions.</td></tr>';
return;
}
tbody.innerHTML = data.map(p => `
<tr>
<td><a class="symbol-link" href="/?symbol=${p.symbol}">${p.symbol}</a></td>
<td>${p.position}</td>
<td class="price-cell">${fmt(p.avg_cost)}</td>
<td><a class="symbol-link" href="/?symbol=${p.symbol}">View Chart</a></td>
</tr>
`).join('');
} catch (e) {
console.error('Positions fetch error:', e);
}
}
function refresh() {
loadPnl();
loadPositions();
}
window.addEventListener('DOMContentLoaded', () => {
refresh();
setInterval(refresh, 30000);
});
</script>
{% endblock %}
+215
View File
@@ -0,0 +1,215 @@
{% extends "layout.html" %}
{% block title %}Market Scanner — IBKR Dashboard{% endblock %}
{% block content %}
<div class="scanner-container">
<div class="scanner-header">
<h1 class="scanner-title">Market Scanner</h1>
<div class="scanner-controls">
<div class="control-group">
<label for="scanType">Scan Type</label>
<select id="scanType">
<option value="HOT_BY_VOLUME" selected>Unusual Volume</option>
<option value="MOST_ACTIVE">Most Active</option>
<option value="TOP_PERC_GAIN">Top Gainers</option>
<option value="TOP_PERC_LOSE">Top Losers</option>
<option value="HIGH_OPEN_GAP">High Open Gap</option>
</select>
</div>
<div class="control-group">
<label for="minPrice">Min Price ($)</label>
<input type="number" id="minPrice" placeholder="e.g. 5.00" step="0.01" min="0">
</div>
<div class="control-group">
<label for="minVolume">Min Volume</label>
<input type="number" id="minVolume" placeholder="e.g. 100000" step="1000" min="0">
</div>
<div class="control-group">
<label for="minMarketCap">Min Market Cap ($M)</label>
<input type="number" id="minMarketCap" placeholder="e.g. 100" step="10" min="0">
</div>
<div class="control-group">
<button class="scan-button" id="scanButton" onclick="runScanner()">Run Scan</button>
</div>
<div class="control-group">
<button class="scan-button export-button" id="exportButton" onclick="exportCSV()">Export CSV</button>
</div>
</div>
</div>
<div class="results-container" id="resultsContainer">
<div class="results-header">
<span>Scanner Results</span>
<span class="results-count" id="resultsCount" style="display:none;">0 results</span>
</div>
<div id="scanResults"></div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
window.addEventListener('DOMContentLoaded', () => {
runScanner();
});
async function runScanner() {
const button = document.getElementById('scanButton');
const scanResults = document.getElementById('scanResults');
const resultsCount = document.getElementById('resultsCount');
button.disabled = true;
button.textContent = 'Scanning...';
scanResults.innerHTML = `
<div class="loading-state">
<div class="scanner-spinner"></div>
<p>Running scanner...</p>
</div>
`;
resultsCount.style.display = 'none';
try {
const payload = {
scan_type: document.getElementById('scanType').value,
min_price: parseFloat(document.getElementById('minPrice').value) || 0,
min_volume: parseInt(document.getElementById('minVolume').value) || 0,
min_market_cap: parseFloat(document.getElementById('minMarketCap').value) || 0,
};
const response = await fetch('/scanner', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();
if (data.status === 'ok') {
displayScanResults(data.results, payload.scan_type);
} else {
showError(data.message || 'Scanner request failed');
}
} catch (err) {
showError('Network error: ' + err.message);
} finally {
button.disabled = false;
button.textContent = 'Run Scan';
}
}
function displayScanResults(results, scanType) {
const scanResults = document.getElementById('scanResults');
const resultsCount = document.getElementById('resultsCount');
resultsCount.textContent = `${results.length} result${results.length !== 1 ? 's' : ''}`;
resultsCount.style.display = 'inline-block';
if (results.length === 0) {
scanResults.innerHTML = '<div class="loading-state"><p>No results found. Try adjusting your filters.</p></div>';
return;
}
const grid = document.createElement('div');
grid.className = 'results-grid';
results.forEach((result) => {
const card = document.createElement('div');
card.className = 'result-card';
card.onclick = () => loadSymbolFromScan(result.symbol);
card.innerHTML = `
<img
class="chart-thumbnail"
src="${result.chart_image}"
alt="${result.symbol} chart"
onerror="handleChartError(this, '${result.symbol}')"
>
<div class="symbol-details">
<div class="symbol-name">${result.symbol}</div>
<div class="symbol-company">${result.company}</div>
<div class="symbol-exchange">${result.exchange || ''}</div>
</div>
`;
grid.appendChild(card);
});
scanResults.innerHTML = '';
scanResults.appendChild(grid);
}
function loadSymbolFromScan(symbol) {
window.location.href = '/?symbol=' + symbol;
}
function formatVolume(volume) {
if (!volume) return '';
if (volume >= 1_000_000) return (volume / 1_000_000).toFixed(1) + 'M';
if (volume >= 1_000) return (volume / 1_000).toFixed(1) + 'K';
return volume.toString();
}
function handleChartError(imgElement, symbol) {
imgElement.style.display = 'none';
const placeholder = document.createElement('div');
placeholder.className = 'chart-placeholder';
placeholder.textContent = symbol;
imgElement.parentNode.insertBefore(placeholder, imgElement);
}
function showError(message) {
const scanResults = document.getElementById('scanResults');
scanResults.innerHTML = `
<div class="loading-state">
<p style="color: #e74c3c;">Error: ${message}</p>
</div>
`;
}
async function exportCSV() {
const button = document.getElementById('exportButton');
button.disabled = true;
button.textContent = 'Exporting...';
try {
const payload = {
scan_type: document.getElementById('scanType').value,
min_price: parseFloat(document.getElementById('minPrice').value) || 0,
min_volume: parseInt(document.getElementById('minVolume').value) || 0,
min_market_cap: parseFloat(document.getElementById('minMarketCap').value) || 0,
};
const response = await fetch('/scanner/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
showError('Export failed');
return;
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename=([^\s;]+)/);
const filename = filenameMatch ? filenameMatch[1] : 'scanner_export.csv';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
showError('Export error: ' + err.message);
} finally {
button.disabled = false;
button.textContent = 'Export CSV';
}
}
</script>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "layout.html" %}
{% block title %}Trade Log — IBKR Dashboard{% endblock %}
{% block content %}
<h1>Trade Log</h1>
<div class="dashboard-container">
{% if trades %}
<table>
<thead>
<tr>
<th>Time (UTC)</th>
<th>Symbol</th>
<th>Action</th>
<th>Quantity</th>
<th>Fill Price</th>
</tr>
</thead>
<tbody>
{% for trade in trades %}
<tr>
<td>{{ trade.timestamp }}</td>
<td class="symbol-cell">
<a href="/?symbol={{ trade.symbol }}" class="symbol-link">{{ trade.symbol }}</a>
</td>
<td>
<span class="action-tag {{ 'buy' if trade.action == 'BUY' else 'sell' }}">{{ trade.action }}</span>
</td>
<td>{{ trade.quantity }}</td>
<td class="price-cell">${{ '%.2f'|format(trade.price) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>No trades have been recorded yet. Executed trades from webhooks will appear here.</p>
</div>
{% endif %}
</div>
{% endblock %}
View File
+322
View File
@@ -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"
+224
View File
@@ -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
+159
View File
@@ -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
+157
View File
@@ -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