initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user