Files
vault-dash/tests/conftest.py

194 lines
5.6 KiB
Python

from __future__ import annotations
import logging
import os
import socket
import sys
import threading
import time
from collections.abc import Generator
from datetime import datetime
from pathlib import Path
import pandas as pd
import pytest
import uvicorn
from app.models.portfolio import LombardPortfolio
from app.strategies.base import StrategyConfig
# Suppress NiceGUI banner noise during test server startup.
logging.getLogger("nicegui").setLevel(logging.WARNING)
def find_free_port() -> int:
"""Find a free port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("", 0))
sock.listen(1)
return int(sock.getsockname()[1])
class ServerManager:
"""Manage a background uvicorn server for Playwright-style tests."""
def __init__(self, port: int) -> None:
self.port = port
self.url = f"http://127.0.0.1:{port}"
self._thread: threading.Thread | None = None
self._server: uvicorn.Server | None = None
def start(self) -> None:
self._thread = threading.Thread(target=self._run_server, daemon=True)
self._thread.start()
if not self._wait_for_connection(timeout=30):
raise RuntimeError("Server did not start within 30 seconds")
def _run_server(self) -> None:
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
os.environ.setdefault("APP_ENV", "test")
os.environ.setdefault("NICEGUI_STORAGE_SECRET", "test-secret-key")
from app.main import app
config = uvicorn.Config(
app,
host="127.0.0.1",
port=self.port,
log_level="warning",
access_log=False,
)
self._server = uvicorn.Server(config)
self._server.run()
def _wait_for_connection(self, timeout: float = 10.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", self.port), timeout=1):
return True
except OSError:
time.sleep(0.1)
return False
def stop(self) -> None:
if self._server is not None:
self._server.should_exit = True
if self._thread is not None and self._thread.is_alive():
self._thread.join(timeout=5)
@pytest.fixture(scope="module")
def server_url() -> Generator[str, None, None]:
"""Start one local app server per Playwright test module."""
server = ServerManager(find_free_port())
server.start()
try:
yield server.url
finally:
server.stop()
@pytest.fixture(scope="module")
def base_url(server_url: str) -> str:
"""Alias used by browser tests."""
return server_url
@pytest.fixture
def sample_portfolio() -> LombardPortfolio:
"""Research-paper baseline portfolio: 1M collateral, 600k loan, 460 spot, 75% LTV trigger."""
gold_ounces = 1_000_000.0 / 460.0
return LombardPortfolio(
gold_ounces=gold_ounces,
gold_price_per_ounce=460.0,
loan_amount=600_000.0,
initial_ltv=0.60,
margin_call_ltv=0.75,
)
@pytest.fixture
def sample_strategy_config(sample_portfolio: LombardPortfolio) -> StrategyConfig:
return StrategyConfig(
portfolio=sample_portfolio,
spot_price=sample_portfolio.gold_price_per_ounce,
volatility=0.16,
risk_free_rate=0.045,
)
@pytest.fixture
def sample_option_chain(sample_portfolio: LombardPortfolio) -> dict[str, object]:
"""Deterministic mock option chain around a 460 GLD reference price."""
spot = sample_portfolio.gold_price_per_ounce
return {
"symbol": "GLD",
"updated_at": datetime(2026, 3, 21, 0, 0).isoformat(),
"source": "mock",
"calls": [
{
"strike": round(spot * 1.05, 2),
"premium": round(spot * 0.03, 2),
"expiry": "2026-06-19",
},
{
"strike": round(spot * 1.10, 2),
"premium": round(spot * 0.02, 2),
"expiry": "2026-09-18",
},
],
"puts": [
{
"strike": round(spot * 0.95, 2),
"premium": round(spot * 0.028, 2),
"expiry": "2026-06-19",
},
{
"strike": round(spot * 0.90, 2),
"premium": round(spot * 0.018, 2),
"expiry": "2026-09-18",
},
],
}
@pytest.fixture
def mock_yfinance_data(monkeypatch):
"""Patch yfinance in the data layer with deterministic historical close data."""
# Lazy import here to avoid side effects when the environment lacks Python 3.11's
# datetime.UTC symbol used in the data_service module.
from app.services import data_service as data_service_module
history = pd.DataFrame(
{"Close": [458.0, 460.0]},
index=pd.date_range("2026-03-20", periods=2, freq="D"),
)
class FakeTicker:
def __init__(self, symbol: str) -> None:
self.symbol = symbol
def history(self, period: str, interval: str):
return history.copy()
class FakeYFinance:
Ticker = FakeTicker
monkeypatch.setattr(data_service_module, "yf", FakeYFinance())
return {
"symbol": "GLD",
"history": history,
"last_price": 460.0,
"previous_price": 458.0,
}
@pytest.fixture
def mock_yfinance(mock_yfinance_data):
"""Compatibility alias for tests that request a yfinance fixture name."""
return mock_yfinance_data