initial commit
This commit is contained in:
@@ -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
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
trades.db
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
+17
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
ib_async
|
||||
sse-starlette
|
||||
jinja2
|
||||
python-dotenv
|
||||
httpx
|
||||
pytest
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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)}
|
||||
@@ -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)}
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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&L</div>
|
||||
<div class="card-value" id="unrealizedPnl">—</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-label">Realized P&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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Executable
+159
@@ -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
|
||||
Executable
+157
@@ -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
|
||||
Reference in New Issue
Block a user