alphabrief / apps /api /tests /conftest.py
Abdr007's picture
AlphaBrief — deployed tree
69e310f
Raw
History Blame Contribute Delete
7.72 kB
"""Shared fixtures.
Test data policy: metrics, verification and degradation tests run against **real
market data pulled live once per session**, not against invented fixtures. Only
*structurally degenerate* inputs (zero bars, one bar) are constructed by hand,
because those shapes cannot be obtained from a working provider — and they are
exactly the shapes the degradation paths must survive.
"""
from __future__ import annotations
import asyncio
import os
import tempfile
from collections.abc import AsyncIterator, Callable, Iterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import pytest
# Configured before any app import so the settings singleton picks these up.
os.environ.setdefault("LLM_ENGINE", "deterministic")
os.environ.setdefault("MCP_TRANSPORT", "inmemory")
os.environ.setdefault("APPROVAL_TOKEN", "test-approval-token")
os.environ.setdefault("PROVIDER_MIN_INTERVAL_SECONDS", "0.05")
os.environ.setdefault("LOG_LEVEL", "WARNING")
os.environ.setdefault(
"DATABASE_URL", f"sqlite:///{Path(tempfile.mkdtemp(prefix='alphabrief-test-')) / 'app.db'}"
)
# The suite must never send real email. `Settings` reads `../../.env`, so an
# operator's working SMTP credentials would otherwise be picked up and the
# integration tests — which approve briefs — would mail a real inbox on every
# run. Assigned rather than setdefault: this has to beat the dotenv file, and
# environment variables take precedence over it.
os.environ["SMTP_HOST"] = ""
os.environ["SMTP_USERNAME"] = ""
os.environ["SMTP_PASSWORD"] = ""
os.environ["SMTP_FROM"] = ""
os.environ["SMTP_TO"] = ""
from app.core.budget import BudgetGuard
from app.core.claude import DeterministicEngine
from app.core.events import EventBus
from app.core.settings import Settings
from app.core.tracing import Tracer
from app.graph.context import RunContext
from app.mcp_server.client import McpToolClient
from app.mcp_server.providers import (
ProviderContext,
fetch_fundamentals,
fetch_price_history,
fetch_rss_news,
)
from app.models.market import Fundamentals, NewsFeed, PriceHistory
LIVE_TICKER = "AAPL"
LIVE_DAYS = 120
@pytest.fixture(scope="session")
def settings(tmp_path_factory: pytest.TempPathFactory) -> Settings:
"""Isolated settings: temp SQLite database, in-memory MCP, offline engine."""
db_path = tmp_path_factory.mktemp("db") / "test.db"
return Settings(
database_url=f"sqlite:///{db_path}",
llm_engine="deterministic",
mcp_transport="inmemory",
approval_token="test-approval-token",
provider_min_interval_seconds=0.05,
price_history_days=LIVE_DAYS,
news_limit=5,
)
@pytest.fixture(scope="session")
def live_market_data() -> dict[str, Any]:
"""Real price history, fundamentals and headlines, fetched once per session.
Skips (rather than fails) when the machine has no network, so the suite is
still usable offline — the network-dependent guarantees are then explicitly
unverified rather than silently faked.
"""
async def _fetch() -> dict[str, Any]:
ctx = ProviderContext(min_interval_seconds=0.05)
history = await fetch_price_history(ctx, LIVE_TICKER, LIVE_DAYS)
fundamentals = await fetch_fundamentals(ctx, LIVE_TICKER)
news = await fetch_rss_news(ctx, LIVE_TICKER, 5)
return {"history": history, "fundamentals": fundamentals, "news": news}
try:
data = asyncio.run(_fetch())
except Exception as exc: # noqa: BLE001 - offline is a skip, not a failure
pytest.skip(f"live market data unavailable: {exc}")
history: PriceHistory = data["history"]
if not history.ok:
pytest.skip(f"live market data unavailable: {history.error}")
return data
@pytest.fixture(scope="session")
def live_history(live_market_data: dict[str, Any]) -> PriceHistory:
history: PriceHistory = live_market_data["history"]
return history
@pytest.fixture(scope="session")
def live_fundamentals(live_market_data: dict[str, Any]) -> Fundamentals:
fundamentals: Fundamentals = live_market_data["fundamentals"]
return fundamentals
@pytest.fixture(scope="session")
def live_news(live_market_data: dict[str, Any]) -> NewsFeed:
feed: NewsFeed = live_market_data["news"]
return feed
@pytest.fixture
def event_bus() -> EventBus:
return EventBus(max_events_per_run=500)
@pytest.fixture
def mcp_factory(settings: Settings) -> Callable[[], McpToolClient]:
"""Build an MCP client the test opens itself.
The session is deliberately **not** opened by a fixture: the MCP SDK uses
anyio task groups, and an async fixture that holds one open across `yield`
enters and exits its cancel scope in different tasks under pytest-asyncio.
Tests open the session inside their own task with `async with`.
"""
def _make() -> McpToolClient:
return McpToolClient(settings=settings)
return _make
def build_run_context(
settings: Settings,
bus: EventBus,
mcp: McpToolClient,
*,
run_id: str = "run_test",
) -> RunContext:
"""A RunContext wired to the offline engine."""
return RunContext(
run_id=run_id,
settings=settings,
engine=DeterministicEngine(settings),
mcp=mcp,
bus=bus,
tracer=Tracer(settings),
budget=BudgetGuard(settings),
)
@asynccontextmanager
async def connected_context(
settings: Settings, bus: EventBus, *, run_id: str = "run_test"
) -> AsyncIterator[RunContext]:
"""A RunContext with a live in-memory MCP session, opened in the caller's task."""
async with McpToolClient(settings=settings) as client:
yield build_run_context(settings, bus, client, run_id=run_id)
@pytest.fixture
def run_context(settings: Settings, event_bus: EventBus) -> RunContext:
"""A RunContext with an unconnected MCP client.
Sufficient for every node that does not call a tool (supervisor, writer,
verifier, gate). Tests that need real tool calls use `connected_context`.
"""
return build_run_context(settings, event_bus, McpToolClient(settings=settings))
@pytest.fixture
def temp_database_url() -> Iterator[str]:
directory = Path(tempfile.mkdtemp())
yield f"sqlite:///{directory / 'repo.db'}"
@pytest.fixture
def live_state(
live_history: PriceHistory, live_fundamentals: Fundamentals, live_news: NewsFeed
) -> Any:
"""A RunState populated from live market data via the real pipeline functions.
Metrics come from the same `compute_metrics_from_bars` the MCP tool calls, and
sentiment from the same lexicon scorer the offline engine uses — so the state
under test is byte-identical to what a real run produces.
"""
from app.core.claude import score_headlines
from app.graph.state import initial_state
from app.mcp_server.registry import compute_metrics_from_bars
from app.models.market import Sentiment
ticker = live_history.ticker
metrics = compute_metrics_from_bars(ticker, list(live_history.bars), live_fundamentals.pe_ratio)
score, reasoning = score_headlines([item.title for item in live_news.items])
state = initial_state(
run_id="run_live",
watchlist_key=ticker,
tickers=[ticker],
session_date="2026-01-02",
)
state["prices"] = {ticker: live_history}
state["fundamentals"] = {ticker: live_fundamentals}
state["metrics"] = {ticker: metrics}
state["news"] = {ticker: live_news}
state["sentiment"] = {
ticker: Sentiment(
ticker=ticker,
score=score,
reasoning=reasoning,
headline_count=len(live_news.items),
)
}
return state