Improve backtest lazy loading and test automation

This commit is contained in:
Bu5hm4nn
2026-04-07 12:18:50 +02:00
parent ccc10923d9
commit b2bc4db41a
18 changed files with 504 additions and 300 deletions

View File

@@ -1,13 +1,102 @@
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:

View File

@@ -1,125 +1 @@
"""Pytest configuration for Playwright tests.
This conftest creates module-scoped fixtures that start the FastAPI server
before running Playwright tests and stop it after all tests complete.
"""
from __future__ import annotations
import logging
import os
import socket
import sys
import threading
import time
from collections.abc import Generator
from pathlib import Path
import pytest
import uvicorn
# Suppress NiceGUI banner noise
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 s:
s.bind(("", 0))
s.listen(1)
port = s.getsockname()[1]
return port
class ServerManager:
"""Manages a NiceGUI/FastAPI server for testing."""
_instance: "ServerManager | None" = None
_lock = threading.Lock()
def __init__(self, port: int) -> None:
self.port = port
self.url = f"http://localhost:{port}"
self._thread: threading.Thread | None = None
self._server: uvicorn.Server | None = None
def start(self) -> None:
"""Start the server in a background thread."""
self._thread = threading.Thread(target=self._run_server, daemon=True)
self._thread.start()
# Wait for server to be ready
if not self._wait_for_connection(timeout=30):
raise RuntimeError("Server did not start within 30 seconds")
def _run_server(self) -> None:
"""Run the FastAPI server with uvicorn."""
# Ensure project root is on sys.path
project_root = str(Path(__file__).parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
os.environ["APP_ENV"] = "test"
os.environ["NICEGUI_STORAGE_SECRET"] = "test-secret-key"
# Import after environment is set
from app.main import app
# Configure uvicorn
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:
"""Wait for server to accept connections."""
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:
"""Stop the server."""
if self._server:
self._server.should_exit = True
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
@classmethod
def get_or_create(cls, port: int) -> "ServerManager":
"""Get existing instance or create new one."""
with cls._lock:
if cls._instance is None:
cls._instance = cls(port)
return cls._instance
@pytest.fixture(scope="module")
def server_url() -> Generator[str, None, None]:
"""Start the server once per module and yield its URL."""
port = find_free_port()
server = ServerManager(port)
# Start server
server.start()
yield server.url
# Cleanup
server.stop()
ServerManager._instance = None
@pytest.fixture(scope="module")
def base_url(server_url: str) -> str:
"""Alias for server_url for naming consistency with Playwright conventions."""
return server_url
"""Root tests/conftest.py provides the shared Playwright server fixtures."""

View File

@@ -11,8 +11,11 @@ from __future__ import annotations
from pathlib import Path
import pytest
from playwright.sync_api import expect
pytestmark = [pytest.mark.playwright, pytest.mark.e2e]
ARTIFACTS = Path("tests/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from datetime import date
from decimal import Decimal
@@ -175,6 +176,49 @@ def test_backtest_page_service_validation_errors_are_user_facing(kwargs: dict[st
service.run_read_only_scenario(**kwargs)
def test_backtest_page_service_fails_closed_for_unsupported_databento_gc_symbol() -> None:
service = BacktestPageService()
with pytest.raises(ValueError, match="Databento backtests currently support GLD and XAU only"):
service.validate_preview_inputs(
symbol="GC",
start_date=date(2024, 7, 1),
end_date=date(2024, 7, 5),
template_slug="protective-put-atm-12m",
underlying_units=1000.0,
loan_amount=68000.0,
margin_call_ltv=0.75,
data_source="databento",
)
def test_backtest_page_service_allows_databento_xau_proxy_symbol() -> None:
service = BacktestPageService()
# Support validation should pass before the provider is consulted.
service.validate_data_source_support("XAU", "databento")
@pytest.mark.skipif(not os.getenv("DATABENTO_API_KEY"), reason="requires DATABENTO_API_KEY")
def test_backtest_page_service_runs_live_databento_gld_scenario() -> None:
service = BacktestPageService()
result = service.run_read_only_scenario(
symbol="GLD",
start_date=date(2024, 7, 1),
end_date=date(2024, 7, 5),
template_slug="protective-put-atm-12m",
underlying_units=1000.0,
loan_amount=68000.0,
margin_call_ltv=0.75,
data_source="databento",
)
assert result.entry_spot > 0
assert len(result.run_result.template_results[0].daily_path) >= 4
assert result.data_cost_usd >= 0
def test_backtest_page_service_fails_closed_outside_seeded_fixture_window() -> None:
"""Test that fixture data fails for dates outside the seeded window."""
service = BacktestPageService()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import os
import tempfile
from datetime import date, timedelta
from pathlib import Path
@@ -306,3 +307,20 @@ class TestDatabentoHistoricalPriceSourceIntegration:
assert stats["total_size_bytes"] > 0
assert len(stats["entries"]) == 1
assert stats["entries"][0]["symbol"] == "GLD"
@pytest.mark.skipif(not os.getenv("DATABENTO_API_KEY"), reason="requires DATABENTO_API_KEY")
def test_live_databento_source_loads_recent_gld_daily_bars(temp_cache_dir: Path) -> None:
source = DatabentoHistoricalPriceSource(
config=DatabentoSourceConfig(
api_key=os.getenv("DATABENTO_API_KEY"),
cache_dir=temp_cache_dir,
schema="ohlcv-1d",
)
)
points = source.load_daily_closes("GLD", date(2024, 7, 1), date(2024, 7, 5))
assert len(points) >= 4
assert points[0].date == date(2024, 7, 1)
assert points[0].close > 0

View File

@@ -2,9 +2,11 @@ from __future__ import annotations
from pathlib import Path
import pytest
from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://127.0.0.1:8100"
pytestmark = [pytest.mark.playwright, pytest.mark.e2e]
ARTIFACTS = Path("tests/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
@@ -46,18 +48,18 @@ def assert_stacked_pane_layout(page, left_testid: str, right_testid: str) -> Non
assert_no_horizontal_overflow(page)
def test_homepage_and_options_page_render() -> None:
def test_homepage_and_options_page_render(base_url: str) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
expect(page).to_have_title("NiceGUI")
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
page.wait_for_url(f"{base_url}/*", timeout=15000)
workspace_url = page.url
workspace_id = workspace_url.removeprefix(f"{BASE_URL}/")
workspace_id = workspace_url.removeprefix(f"{base_url}/")
assert workspace_id
workspace_cookie = None
for _ in range(5):
@@ -80,7 +82,7 @@ def test_homepage_and_options_page_render() -> None:
assert_stacked_pane_layout(page, "overview-left-pane", "overview-right-pane")
page.set_viewport_size({"width": 1440, "height": 1000})
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
page.wait_for_url(workspace_url, timeout=15000)
expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000)
@@ -112,7 +114,7 @@ def test_homepage_and_options_page_render() -> None:
assert "Traceback" not in event_text
page.screenshot(path=str(ARTIFACTS / "event-comparison.png"), full_page=True)
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)
page.goto(f"{base_url}/options", wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Options Chain").first).to_be_visible(timeout=15000)
expect(page.locator("text=Filters").first).to_be_visible(timeout=15000)
assert_two_pane_layout(page, "options-left-pane", "options-right-pane")
@@ -223,10 +225,10 @@ def test_homepage_and_options_page_render() -> None:
second_context = browser.new_context(viewport={"width": 1440, "height": 1000})
second_page = second_context.new_page()
second_page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
second_page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
expect(second_page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
second_page.get_by_role("button", name="Get started").click()
second_page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
second_page.wait_for_url(f"{base_url}/*", timeout=15000)
second_workspace_url = second_page.url
assert second_workspace_url != workspace_url
second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)
@@ -285,7 +287,7 @@ def test_homepage_and_options_page_render() -> None:
browser.close()
def test_backtest_page_loads_with_valid_databento_dates() -> None:
def test_backtest_page_loads_with_valid_databento_dates(base_url: str) -> None:
"""E2E test: Backtest page loads and validates Databento date range.
Regression test for CORE-003: Ensures backtest page handles Databento
@@ -296,10 +298,10 @@ def test_backtest_page_loads_with_valid_databento_dates() -> None:
page = browser.new_page(viewport={"width": 1440, "height": 1000})
# Create a workspace first (backtests page requires workspace_id)
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
page.wait_for_url(f"{base_url}/*", timeout=15000)
workspace_url = page.url
# Navigate to backtests page with workspace
@@ -316,11 +318,12 @@ def test_backtest_page_loads_with_valid_databento_dates() -> None:
dataset = page.locator("[data-testid=dataset-select]")
expect(dataset).to_be_visible()
# Verify date range hint is visible (current behavior shows GLD hint on initial render)
# TODO: When Databento is selected by default, hint should show "XNAS.BASIC data available from 2024-07-01"
# Bug: update_date_range_hint() is not called on initial render for Databento
date_hint = page.locator("text=GLD data available from")
expect(date_hint).to_be_visible(timeout=5000)
# Verify dataset-specific availability hint and lazy preview copy are visible.
expect(page.locator("text=XNAS.BASIC data available from 2024-07-01")).to_be_visible(timeout=5000)
expect(
page.get_by_text("Preview not loaded yet. Heavy Databento lookups stay lazy until you request them.").first
).to_be_visible(timeout=5000)
expect(page.get_by_role("button", name="Load scenario preview")).to_be_visible(timeout=5000)
# Verify start date input has a valid date (dynamic default based on current date)
start_input = page.get_by_label("Start date")
@@ -337,7 +340,7 @@ def test_backtest_page_loads_with_valid_databento_dates() -> None:
browser.close()
def test_backtest_page_handles_invalid_dates_gracefully() -> None:
def test_backtest_page_handles_invalid_dates_gracefully(base_url: str) -> None:
"""E2E test: Backtest page shows validation error for invalid dates.
Regression test: Ensures user-friendly error instead of 500 when
@@ -348,10 +351,10 @@ def test_backtest_page_handles_invalid_dates_gracefully() -> None:
page = browser.new_page(viewport={"width": 1440, "height": 1000})
# Create a workspace first (backtests page requires workspace_id)
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
page.wait_for_url(f"{base_url}/*", timeout=15000)
workspace_url = page.url
# Navigate to backtests page with workspace
@@ -373,7 +376,7 @@ def test_backtest_page_handles_invalid_dates_gracefully() -> None:
browser.close()
def test_backtest_scenario_runs_and_displays_results() -> None:
def test_backtest_scenario_runs_and_displays_results(base_url: str) -> None:
"""E2E test: Full backtest scenario execution with synthetic data.
This test verifies that:
@@ -389,13 +392,13 @@ def test_backtest_scenario_runs_and_displays_results() -> None:
page = browser.new_page(viewport={"width": 1440, "height": 1000})
# Step 1: Create a workspace
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
expect(page).to_have_title("NiceGUI")
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
page.wait_for_url(f"{base_url}/*", timeout=15000)
workspace_url = page.url
workspace_id = workspace_url.removeprefix(f"{BASE_URL}/")
workspace_id = workspace_url.removeprefix(f"{base_url}/")
assert workspace_id, "Should have workspace ID in URL"
# Step 2: Navigate to backtests page with workspace

View File

@@ -3,23 +3,25 @@ from __future__ import annotations
from pathlib import Path
from uuid import uuid4
import pytest
from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://127.0.0.1:8100"
pytestmark = [pytest.mark.playwright, pytest.mark.e2e]
ARTIFACTS = Path("tests/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
def test_hedge_builder_saves_template_and_reuses_it_in_backtests() -> None:
def test_hedge_builder_saves_template_and_reuses_it_in_backtests(base_url: str) -> None:
template_name = f"Crash Guard 95 {uuid4().hex[:8]}"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
page.wait_for_url(f"{base_url}/*", timeout=15000)
workspace_url = page.url
page.goto(f"{workspace_url}/hedge", wait_until="domcontentloaded", timeout=30000)

View File

@@ -2,22 +2,24 @@ from __future__ import annotations
from pathlib import Path
import pytest
from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://127.0.0.1:8100"
pytestmark = [pytest.mark.playwright, pytest.mark.e2e]
ARTIFACTS = Path("tests/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
def test_overview_shows_ltv_history_and_exports_csv() -> None:
def test_overview_shows_ltv_history_and_exports_csv(base_url: str) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=15000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
page.wait_for_url(f"{base_url}/*", timeout=15000)
expect(page.locator("text=Overview").first).to_be_visible(timeout=15000)
expect(page.locator("text=Historical LTV").first).to_be_visible(timeout=15000)

View File

@@ -134,6 +134,14 @@ class TestGetDefaultBacktestDates:
start, end = get_default_backtest_dates()
assert start < end
# def test_dates_cover_recent_completed_week(self) -> None:
# """Default window should be a completed Monday-Friday backtest week."""
# start, end = get_default_backtest_dates()
# assert start.weekday() == 0
# assert end.weekday() == 4
# delta = end - start
# assert delta.days == 4, f"Delta should be 4 days for Monday-Friday window, got {delta.days}"
def test_dates_are_fixed_march_2026(self) -> None:
"""Test that dates are fixed to March 2026 for testing."""
start, end = get_default_backtest_dates()
@@ -142,15 +150,16 @@ class TestGetDefaultBacktestDates:
delta = end - start
assert delta.days == 23, f"Delta should be 23 days, got {delta.days}"
def test_end_is_fixed_date(self) -> None:
"""Test that end date is the fixed March 25 date."""
start, end = get_default_backtest_dates()
assert end == date(2026, 3, 25)
def test_end_is_not_in_future(self) -> None:
"""Default end date should never point to a future trading day."""
_, end = get_default_backtest_dates()
assert end <= date.today()
def test_start_is_fixed_date(self) -> None:
"""Test that start date is the fixed March 2 date."""
start, end = get_default_backtest_dates()
assert start == date(2026, 3, 2)
def test_databento_defaults_respect_dataset_min_date(self) -> None:
"""Databento defaults should never predate dataset availability."""
start, end = get_default_backtest_dates(data_source="databento", dataset="XNAS.BASIC")
assert start >= date(2024, 7, 1)
assert end >= start
class TestSymbolMinDates:

View File

@@ -1,19 +1,20 @@
from __future__ import annotations
import pytest
from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://127.0.0.1:8100"
pytestmark = [pytest.mark.playwright, pytest.mark.e2e]
def test_settings_reject_invalid_loan_amount_without_silent_zero_fallback() -> None:
def test_settings_reject_invalid_loan_amount_without_silent_zero_fallback(base_url: str) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.goto(base_url, wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Create a private workspace URL").first).to_be_visible(timeout=10000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
page.wait_for_url(f"{base_url}/*", timeout=15000)
workspace_url = page.url
page.goto(f"{workspace_url}/settings", wait_until="domcontentloaded", timeout=30000)