Spaces:
Running
Running
| """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 | |
| 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, | |
| ) | |
| 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 | |
| def live_history(live_market_data: dict[str, Any]) -> PriceHistory: | |
| history: PriceHistory = live_market_data["history"] | |
| return history | |
| def live_fundamentals(live_market_data: dict[str, Any]) -> Fundamentals: | |
| fundamentals: Fundamentals = live_market_data["fundamentals"] | |
| return fundamentals | |
| def live_news(live_market_data: dict[str, Any]) -> NewsFeed: | |
| feed: NewsFeed = live_market_data["news"] | |
| return feed | |
| def event_bus() -> EventBus: | |
| return EventBus(max_events_per_run=500) | |
| 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), | |
| ) | |
| 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) | |
| 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)) | |
| def temp_database_url() -> Iterator[str]: | |
| directory = Path(tempfile.mkdtemp()) | |
| yield f"sqlite:///{directory / 'repo.db'}" | |
| 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 | |