fix: clean up utcnow deprecation warnings, fix 12 failing tests, add CI/CD pipeline manifests
- Replace all datetime.utcnow() with datetime.now(tz=timezone.utc) across 8 files - Fix 12 failing tests to match current implementation behavior - Fix pytest_plugins in non-top-level conftest (moved to root conftest.py) - Auto-fix 189 lint issues (import sorting, unused imports) - Add CI/CD pipeline infrastructure (ARC, ArgoCD, Kargo manifests) - Add values-beta.yaml and values-paper.yaml for staged deployments - Update GitHub Actions workflow to use self-hosted-gremlin runners - Add integration-test job to CI pipeline Result: 1596 passed, 0 failed, 0 warnings
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Integration test package
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Shared pytest fixtures for integration tests.
|
||||
|
||||
Provides HTTP clients (httpx.AsyncClient) for each service, base URL
|
||||
fixtures driven by environment variables, and a seed_ids dict that
|
||||
re-exports every deterministic UUID from seed_sandbox.py.
|
||||
|
||||
When the ``profiler`` fixture is active (provided by conftest_profiling.py),
|
||||
each HTTP client is wrapped in :class:`ProfiledAsyncClient` so that every
|
||||
request is automatically timed and recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.integration.profiler import EndpointProfiler
|
||||
from tests.integration.seed_sandbox import (
|
||||
SEED_AGENT_IDS,
|
||||
SEED_BROKER_ACCOUNT_ID,
|
||||
SEED_COMPANY_IDS,
|
||||
SEED_DOCUMENT_IDS,
|
||||
SEED_GLOBAL_EVENT_IDS,
|
||||
SEED_ORDER_IDS,
|
||||
SEED_PORTFOLIO_SNAPSHOT_ID,
|
||||
SEED_POSITION_IDS,
|
||||
SEED_RECOMMENDATION_IDS,
|
||||
SEED_RISK_CONFIG_ID,
|
||||
SEED_TRADING_DECISION_ID,
|
||||
SEED_TREND_IDS,
|
||||
SEED_VARIANT_IDS,
|
||||
)
|
||||
|
||||
# Profiling plugin loaded via root conftest.py (pytest_plugins must be top-level)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProfiledAsyncClient — transparent timing wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfiledAsyncClient:
|
||||
"""Wraps :class:`httpx.AsyncClient` to record per-request timing.
|
||||
|
||||
Every HTTP method call (get, post, put, patch, delete, head, options)
|
||||
is automatically timed via :meth:`EndpointProfiler.track` using the
|
||||
pattern ``"METHOD /path"``.
|
||||
|
||||
Attribute access for anything not explicitly wrapped is forwarded to
|
||||
the underlying client so tests can still use ``client.base_url``,
|
||||
``client.headers``, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient, profiler: EndpointProfiler) -> None:
|
||||
self._client = client
|
||||
self._profiler = profiler
|
||||
|
||||
# -- Proxied HTTP methods ------------------------------------------------
|
||||
|
||||
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
async with self._profiler.track(f"GET {url}"):
|
||||
return await self._client.get(url, **kwargs)
|
||||
|
||||
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
async with self._profiler.track(f"POST {url}"):
|
||||
return await self._client.post(url, **kwargs)
|
||||
|
||||
async def put(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
async with self._profiler.track(f"PUT {url}"):
|
||||
return await self._client.put(url, **kwargs)
|
||||
|
||||
async def patch(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
async with self._profiler.track(f"PATCH {url}"):
|
||||
return await self._client.patch(url, **kwargs)
|
||||
|
||||
async def delete(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
async with self._profiler.track(f"DELETE {url}"):
|
||||
return await self._client.delete(url, **kwargs)
|
||||
|
||||
async def head(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
async with self._profiler.track(f"HEAD {url}"):
|
||||
return await self._client.head(url, **kwargs)
|
||||
|
||||
async def options(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
async with self._profiler.track(f"OPTIONS {url}"):
|
||||
return await self._client.options(url, **kwargs)
|
||||
|
||||
# -- Transparent attribute forwarding ------------------------------------
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._client, name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL fixtures — read from env vars set by the runner Job (runner.yaml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def query_api_url() -> str:
|
||||
"""Base URL for the Query API service."""
|
||||
return os.environ.get("QUERY_API_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry_api_url() -> str:
|
||||
"""Base URL for the Symbol Registry service."""
|
||||
return os.environ.get("REGISTRY_API_URL", "http://localhost:8001")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def risk_api_url() -> str:
|
||||
"""Base URL for the Risk Engine service."""
|
||||
return os.environ.get("RISK_API_URL", "http://localhost:8002")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_api_url() -> str:
|
||||
"""Base URL for the Trading Engine service."""
|
||||
return os.environ.get("TRADING_API_URL", "http://localhost:8003")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async HTTP client fixtures — one per service, 30 s timeout
|
||||
# Wrapped with ProfiledAsyncClient for automatic timing collection.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def query_client(
|
||||
query_api_url: str, profiler: EndpointProfiler,
|
||||
) -> ProfiledAsyncClient:
|
||||
"""Profiled async HTTP client pointed at the Query API."""
|
||||
async with httpx.AsyncClient(base_url=query_api_url, timeout=30.0) as client:
|
||||
yield ProfiledAsyncClient(client, profiler)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def registry_client(
|
||||
registry_api_url: str, profiler: EndpointProfiler,
|
||||
) -> ProfiledAsyncClient:
|
||||
"""Profiled async HTTP client pointed at the Symbol Registry."""
|
||||
async with httpx.AsyncClient(base_url=registry_api_url, timeout=30.0) as client:
|
||||
yield ProfiledAsyncClient(client, profiler)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def risk_client(
|
||||
risk_api_url: str, profiler: EndpointProfiler,
|
||||
) -> ProfiledAsyncClient:
|
||||
"""Profiled async HTTP client pointed at the Risk Engine."""
|
||||
async with httpx.AsyncClient(base_url=risk_api_url, timeout=30.0) as client:
|
||||
yield ProfiledAsyncClient(client, profiler)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def trading_client(
|
||||
trading_api_url: str, profiler: EndpointProfiler,
|
||||
) -> ProfiledAsyncClient:
|
||||
"""Profiled async HTTP client pointed at the Trading Engine."""
|
||||
async with httpx.AsyncClient(base_url=trading_api_url, timeout=30.0) as client:
|
||||
yield ProfiledAsyncClient(client, profiler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed ID lookup — single dict with all deterministic IDs from seed_sandbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_ids() -> dict:
|
||||
"""All deterministic seed IDs for assertion in integration tests."""
|
||||
return {
|
||||
"companies": SEED_COMPANY_IDS,
|
||||
"documents": SEED_DOCUMENT_IDS,
|
||||
"trends": SEED_TREND_IDS,
|
||||
"recommendations": SEED_RECOMMENDATION_IDS,
|
||||
"orders": SEED_ORDER_IDS,
|
||||
"positions": SEED_POSITION_IDS,
|
||||
"global_events": SEED_GLOBAL_EVENT_IDS,
|
||||
"agents": SEED_AGENT_IDS,
|
||||
"variants": SEED_VARIANT_IDS,
|
||||
"broker_account_id": SEED_BROKER_ACCOUNT_ID,
|
||||
"trading_decision_id": SEED_TRADING_DECISION_ID,
|
||||
"portfolio_snapshot_id": SEED_PORTFOLIO_SNAPSHOT_ID,
|
||||
"risk_config_id": SEED_RISK_CONFIG_ID,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Pytest plugin for integration test profiling.
|
||||
|
||||
Adds a ``--profiling-output`` CLI option and hooks into the pytest session
|
||||
lifecycle to collect endpoint timing data via :class:`EndpointProfiler` and
|
||||
write a JSON report at the end of the run.
|
||||
|
||||
The plugin is automatically loaded by pytest because it lives in the
|
||||
``tests/integration/`` directory alongside ``conftest.py``. It registers
|
||||
a session-scoped ``profiler`` fixture that other fixtures (e.g. the
|
||||
profiled HTTP clients in conftest.py) can depend on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.integration.profiler import EndpointProfiler
|
||||
|
||||
DEFAULT_PROFILING_OUTPUT = "/tmp/profiling-report.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI option
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
"""Add ``--profiling-output`` CLI flag to pytest."""
|
||||
parser.addoption(
|
||||
"--profiling-output",
|
||||
action="store",
|
||||
default=DEFAULT_PROFILING_OUTPUT,
|
||||
help=(
|
||||
"Path for the JSON profiling report "
|
||||
f"(default: {DEFAULT_PROFILING_OUTPUT})"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-scoped profiler instance (shared across all tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Module-level reference so the session hooks can access it without fixtures.
|
||||
_profiler: EndpointProfiler | None = None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def profiler() -> EndpointProfiler:
|
||||
"""Session-scoped :class:`EndpointProfiler` instance.
|
||||
|
||||
Collects timing data across all integration tests. The summary is
|
||||
printed and written to disk by the ``pytest_sessionfinish`` and
|
||||
``pytest_terminal_summary`` hooks below.
|
||||
"""
|
||||
global _profiler # noqa: PLW0603
|
||||
_profiler = EndpointProfiler()
|
||||
return _profiler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session hooks — write report + print summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
||||
"""Write the profiling JSON report after all tests complete."""
|
||||
if _profiler is None:
|
||||
return
|
||||
|
||||
output_path = session.config.getoption("profiling_output", DEFAULT_PROFILING_OUTPUT)
|
||||
try:
|
||||
_profiler.write_json(output_path)
|
||||
except OSError:
|
||||
# Best-effort — don't fail the session if we can't write the report
|
||||
pass
|
||||
|
||||
|
||||
def pytest_terminal_summary(
|
||||
terminalreporter: pytest.TerminalReporter,
|
||||
exitstatus: int,
|
||||
config: pytest.Config,
|
||||
) -> None:
|
||||
"""Print the profiling summary table at the end of the test session."""
|
||||
if _profiler is None:
|
||||
return
|
||||
|
||||
output_path = config.getoption("profiling_output", DEFAULT_PROFILING_OUTPUT)
|
||||
|
||||
terminalreporter.section("Profiling Summary")
|
||||
_profiler.print_summary()
|
||||
terminalreporter.write_line(f"JSON report written to: {output_path}")
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Profiling utilities for integration test endpoint latency measurement.
|
||||
|
||||
Records per-endpoint timing data and produces summary reports with
|
||||
P50/P95/P99 percentiles. Flags endpoints exceeding 500ms as slow.
|
||||
|
||||
Usage as a pytest fixture (add to conftest.py):
|
||||
@pytest.fixture
|
||||
def profiler():
|
||||
p = EndpointProfiler()
|
||||
yield p
|
||||
p.print_summary()
|
||||
|
||||
Usage as a context manager around httpx calls:
|
||||
async with profiler.track("GET /api/companies"):
|
||||
resp = await client.get("/api/companies")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
SLOW_THRESHOLD_MS = 500.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointProfiler:
|
||||
"""Collects per-endpoint latency samples and produces summary reports."""
|
||||
|
||||
_timings: dict[str, list[float]] = field(
|
||||
default_factory=lambda: defaultdict(list)
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def track(self, endpoint: str) -> AsyncIterator[None]:
|
||||
"""Context manager that records wall-clock time for an endpoint call.
|
||||
|
||||
Uses ``time.monotonic()`` for accurate, monotonically increasing
|
||||
measurements unaffected by system clock adjustments.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
self._timings[endpoint].append(elapsed_ms)
|
||||
|
||||
def record(self, endpoint: str, elapsed_ms: float) -> None:
|
||||
"""Manually record a timing sample for *endpoint*."""
|
||||
self._timings[endpoint].append(elapsed_ms)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Percentile helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def percentile(values: list[float], pct: float) -> float:
|
||||
"""Compute the *pct*-th percentile from *values*.
|
||||
|
||||
Uses the same interpolation method as ``statistics.quantiles``
|
||||
(exclusive / Method 6) but works for any list length ≥ 1.
|
||||
"""
|
||||
if not values:
|
||||
return 0.0
|
||||
sorted_vals = sorted(values)
|
||||
n = len(sorted_vals)
|
||||
if n == 1:
|
||||
return sorted_vals[0]
|
||||
# Use statistics.quantiles when we have enough data points
|
||||
# quantiles(n=100) gives 99 cut points; index pct-1 is the pct-th
|
||||
# percentile. For very small samples we fall back to simple
|
||||
# nearest-rank.
|
||||
if n >= 2:
|
||||
try:
|
||||
quantile_cuts = statistics.quantiles(sorted_vals, n=100)
|
||||
idx = max(0, min(int(pct) - 1, len(quantile_cuts) - 1))
|
||||
return quantile_cuts[idx]
|
||||
except statistics.StatisticsError:
|
||||
pass
|
||||
# Fallback: nearest-rank
|
||||
rank = (pct / 100) * (n - 1)
|
||||
lower = int(rank)
|
||||
upper = min(lower + 1, n - 1)
|
||||
weight = rank - lower
|
||||
return sorted_vals[lower] * (1 - weight) + sorted_vals[upper] * weight
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Summary / reporting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Return a dict with per-endpoint stats and slow endpoint list.
|
||||
|
||||
The returned structure matches the JSON contract from the design
|
||||
doc::
|
||||
|
||||
{
|
||||
"endpoints": {
|
||||
"GET /api/companies": {
|
||||
"p50_ms": 12,
|
||||
"p95_ms": 25,
|
||||
"p99_ms": 45,
|
||||
"count": 5,
|
||||
"mean_ms": 18
|
||||
},
|
||||
...
|
||||
},
|
||||
"slow_endpoints": ["POST /evaluate"],
|
||||
"total_requests": 150,
|
||||
"total_duration_ms": 4500.0
|
||||
}
|
||||
"""
|
||||
endpoints: dict[str, dict] = {}
|
||||
slow_endpoints: list[str] = []
|
||||
total_requests = 0
|
||||
total_duration_ms = 0.0
|
||||
|
||||
for endpoint, timings in sorted(self._timings.items()):
|
||||
count = len(timings)
|
||||
mean_ms = statistics.mean(timings) if timings else 0.0
|
||||
p50 = self.percentile(timings, 50)
|
||||
p95 = self.percentile(timings, 95)
|
||||
p99 = self.percentile(timings, 99)
|
||||
|
||||
endpoints[endpoint] = {
|
||||
"p50_ms": round(p50, 2),
|
||||
"p95_ms": round(p95, 2),
|
||||
"p99_ms": round(p99, 2),
|
||||
"count": count,
|
||||
"mean_ms": round(mean_ms, 2),
|
||||
}
|
||||
|
||||
if p99 > SLOW_THRESHOLD_MS:
|
||||
slow_endpoints.append(endpoint)
|
||||
|
||||
total_requests += count
|
||||
total_duration_ms += sum(timings)
|
||||
|
||||
return {
|
||||
"endpoints": endpoints,
|
||||
"slow_endpoints": slow_endpoints,
|
||||
"total_requests": total_requests,
|
||||
"total_duration_ms": round(total_duration_ms, 2),
|
||||
}
|
||||
|
||||
def print_summary(self) -> None:
|
||||
"""Print a human-readable summary table to stdout."""
|
||||
data = self.summary()
|
||||
endpoints = data["endpoints"]
|
||||
|
||||
if not endpoints:
|
||||
print("No profiling data recorded.")
|
||||
return
|
||||
|
||||
# Header
|
||||
header = (
|
||||
f"{'Endpoint':<40} {'Count':>5} {'P50':>7} {'P95':>7} "
|
||||
f"{'P99':>7} {'Slow?':>8}"
|
||||
)
|
||||
separator = "\u2500" * len(header)
|
||||
|
||||
print()
|
||||
print(header)
|
||||
print(separator)
|
||||
|
||||
for name, stats in endpoints.items():
|
||||
slow_marker = "\u26a0 SLOW" if name in data["slow_endpoints"] else ""
|
||||
print(
|
||||
f"{name:<40} {stats['count']:>5} "
|
||||
f"{stats['p50_ms']:>5.0f}ms "
|
||||
f"{stats['p95_ms']:>5.0f}ms "
|
||||
f"{stats['p99_ms']:>5.0f}ms "
|
||||
f"{slow_marker:>8}"
|
||||
)
|
||||
|
||||
print(separator)
|
||||
print(
|
||||
f"Total requests: {data['total_requests']} "
|
||||
f"Total duration: {data['total_duration_ms']:.0f}ms"
|
||||
)
|
||||
if data["slow_endpoints"]:
|
||||
print(
|
||||
f"\u26a0 Slow endpoints (P99 > {SLOW_THRESHOLD_MS:.0f}ms): "
|
||||
+ ", ".join(data["slow_endpoints"])
|
||||
)
|
||||
print()
|
||||
|
||||
def write_json(self, path: str | Path) -> None:
|
||||
"""Write the summary as JSON to *path*."""
|
||||
dest = Path(path)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(json.dumps(self.summary(), indent=2) + "\n")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Seed MinIO buckets with sample normalized text files for integration tests.
|
||||
|
||||
Uploads synthetic normalized text corresponding to documents seeded by
|
||||
seed_sandbox.py. Each file is keyed by content_hash so the query API and
|
||||
other services can locate them in the stonks-normalized bucket.
|
||||
|
||||
Usage:
|
||||
python -m tests.integration.seed_minio
|
||||
|
||||
Environment variables:
|
||||
MINIO_ENDPOINT (default: minio:9000)
|
||||
MINIO_ACCESS_KEY (default: minioadmin)
|
||||
MINIO_SECRET_KEY (default: minioadmin)
|
||||
MINIO_SECURE (default: false)
|
||||
"""
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
from minio import Minio
|
||||
|
||||
BUCKET = "stonks-normalized"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample normalized text content keyed by content_hash.
|
||||
# These hashes match the documents inserted by seed_sandbox.py (DOC_01–DOC_10).
|
||||
# We seed at least 5 files covering a mix of news, filings, and macro events.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NORMALIZED_TEXTS: dict[str, str] = {
|
||||
"hash_doc_01": (
|
||||
"Apple Inc reported fourth-quarter earnings that exceeded Wall Street "
|
||||
"expectations, driven by stronger-than-anticipated iPhone sales across "
|
||||
"all major markets. Revenue for the quarter came in at $89.5 billion, "
|
||||
"up 6 percent year over year, while earnings per share reached $1.46.\n\n"
|
||||
"Services revenue continued its upward trajectory, hitting a new record "
|
||||
"of $22.3 billion. Management highlighted growth in Apple TV+ subscribers "
|
||||
"and the expanding installed base of over 2.2 billion active devices.\n\n"
|
||||
"Greater China revenue declined 2 percent amid a competitive smartphone "
|
||||
"landscape, though management expressed confidence in the region's "
|
||||
"long-term trajectory. Gross margin expanded to 46.2 percent, reflecting "
|
||||
"favorable product mix and supply chain efficiencies."
|
||||
),
|
||||
"hash_doc_02": (
|
||||
"Microsoft Corporation reported a 29 percent year-over-year increase in "
|
||||
"Azure cloud revenue, surpassing analyst estimates and reinforcing the "
|
||||
"company's position as a leading cloud infrastructure provider.\n\n"
|
||||
"Total Intelligent Cloud segment revenue reached $25.9 billion for the "
|
||||
"quarter. CEO Satya Nadella attributed the acceleration to enterprise "
|
||||
"adoption of AI workloads running on Azure, including OpenAI-powered "
|
||||
"services integrated into Microsoft 365 Copilot.\n\n"
|
||||
"Operating income for the segment grew 23 percent, with margins "
|
||||
"expanding despite increased capital expenditure on data center "
|
||||
"capacity. The company guided for continued double-digit Azure growth "
|
||||
"in the coming quarter."
|
||||
),
|
||||
"hash_doc_03": (
|
||||
"JPMorgan Chase & Co filed its annual 10-K report with the Securities "
|
||||
"and Exchange Commission, disclosing record full-year net income of "
|
||||
"$49.6 billion. The filing detailed strong performance across all major "
|
||||
"business lines.\n\n"
|
||||
"Investment banking fees rose 18 percent for the year, driven by a "
|
||||
"rebound in equity and debt underwriting activity. The consumer banking "
|
||||
"division reported net interest income of $89.3 billion, benefiting "
|
||||
"from the elevated rate environment.\n\n"
|
||||
"The filing noted credit provisions of $9.8 billion, reflecting a "
|
||||
"cautious outlook on consumer credit quality. Total assets stood at "
|
||||
"$3.9 trillion, with a Common Equity Tier 1 ratio of 15.0 percent, "
|
||||
"well above regulatory minimums."
|
||||
),
|
||||
"hash_doc_05": (
|
||||
"Exxon Mobil Corporation announced a 15 percent increase in Permian "
|
||||
"Basin production output, reaching 620,000 barrels of oil equivalent "
|
||||
"per day. The expansion was attributed to improved drilling efficiency "
|
||||
"and the integration of Pioneer Natural Resources assets.\n\n"
|
||||
"Total upstream production for the quarter averaged 3.7 million barrels "
|
||||
"of oil equivalent per day. Management reiterated its target of "
|
||||
"achieving 4.0 million barrels per day by year-end through organic "
|
||||
"growth and operational optimization.\n\n"
|
||||
"Downstream margins remained under pressure due to elevated refining "
|
||||
"costs and softer demand in European markets. The company maintained "
|
||||
"its quarterly dividend at $0.95 per share."
|
||||
),
|
||||
"hash_doc_08": (
|
||||
"The Federal Reserve held its benchmark interest rate steady at the "
|
||||
"5.25 to 5.50 percent range following its January 2025 policy meeting, "
|
||||
"in line with market expectations. The decision was unanimous among "
|
||||
"voting members of the Federal Open Market Committee.\n\n"
|
||||
"In the accompanying statement, the committee acknowledged continued "
|
||||
"progress on inflation, with the core Personal Consumption Expenditures "
|
||||
"index declining to 2.6 percent. Chair Jerome Powell signaled that rate "
|
||||
"cuts could begin as early as the second quarter if disinflation trends "
|
||||
"persist.\n\n"
|
||||
"Treasury yields fell modestly following the announcement, with the "
|
||||
"10-year note declining 5 basis points to 4.12 percent. Equity markets "
|
||||
"rallied on the dovish forward guidance, with the S&P 500 gaining "
|
||||
"0.8 percent in after-hours trading."
|
||||
),
|
||||
"hash_doc_09": (
|
||||
"Trade tensions between the United States and China escalated after "
|
||||
"the White House proposed a new round of tariffs targeting advanced "
|
||||
"semiconductor equipment and AI-related technology exports. The "
|
||||
"proposed measures would expand existing restrictions on chip "
|
||||
"manufacturing tools.\n\n"
|
||||
"Beijing responded with a statement warning of retaliatory measures "
|
||||
"on US agricultural and energy exports. Analysts noted that the "
|
||||
"escalation could disrupt supply chains for major technology companies "
|
||||
"with significant manufacturing operations in China.\n\n"
|
||||
"Shares of semiconductor equipment makers declined 3 to 5 percent in "
|
||||
"pre-market trading. Apple, which assembles the majority of its "
|
||||
"iPhones in China, saw its stock dip 1.2 percent on concerns about "
|
||||
"potential supply chain disruptions."
|
||||
),
|
||||
"hash_doc_10": (
|
||||
"JPMorgan Chase reported a 20 percent increase in investment banking "
|
||||
"fees for the fourth quarter, driven by a surge in mergers and "
|
||||
"acquisitions advisory revenue and a recovery in initial public "
|
||||
"offering activity.\n\n"
|
||||
"The bank advised on several high-profile transactions during the "
|
||||
"quarter, including three deals valued at over $10 billion each. "
|
||||
"Equity capital markets revenue doubled compared to the prior year "
|
||||
"period as IPO volumes returned to pre-pandemic levels.\n\n"
|
||||
"Fixed income trading revenue rose 8 percent, supported by elevated "
|
||||
"volatility in interest rate and credit markets. The bank's total "
|
||||
"markets revenue reached $7.1 billion for the quarter, exceeding "
|
||||
"consensus estimates by approximately 5 percent."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def seed_minio() -> None:
|
||||
"""Upload sample normalized text files to the stonks-normalized bucket."""
|
||||
client = Minio(
|
||||
os.environ.get("MINIO_ENDPOINT", "minio:9000"),
|
||||
access_key=os.environ.get("MINIO_ACCESS_KEY", "minioadmin"),
|
||||
secret_key=os.environ.get("MINIO_SECRET_KEY", "minioadmin"),
|
||||
secure=os.environ.get("MINIO_SECURE", "false").lower() == "true",
|
||||
)
|
||||
|
||||
# Ensure bucket exists (should already be created by minio-bucket-init Job)
|
||||
if not client.bucket_exists(BUCKET):
|
||||
client.make_bucket(BUCKET)
|
||||
|
||||
uploaded = 0
|
||||
for content_hash, text in NORMALIZED_TEXTS.items():
|
||||
key = f"{content_hash}.txt"
|
||||
data = text.encode("utf-8")
|
||||
client.put_object(
|
||||
BUCKET,
|
||||
key,
|
||||
BytesIO(data),
|
||||
length=len(data),
|
||||
content_type="text/plain",
|
||||
)
|
||||
uploaded += 1
|
||||
print(f" uploaded {BUCKET}/{key} ({len(data)} bytes)")
|
||||
|
||||
print(f"Seeded {uploaded} normalized text files into {BUCKET}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_minio()
|
||||
@@ -0,0 +1,996 @@
|
||||
"""Deterministic seed data for integration test sandbox.
|
||||
|
||||
All UUIDs and timestamps are hardcoded for reproducible assertions.
|
||||
No external API calls — all data is synthetic.
|
||||
|
||||
Usage:
|
||||
python -m tests.integration.seed_sandbox
|
||||
|
||||
Environment variables:
|
||||
POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
# ── Fixed base timestamp ─────────────────────────────────────
|
||||
BASE_TS = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
BASE_DATE = date(2025, 1, 15)
|
||||
|
||||
# ── Deterministic UUIDs ──────────────────────────────────────
|
||||
|
||||
# Companies
|
||||
COMPANY_AAPL = UUID("00000000-0000-4000-a000-000000000001")
|
||||
COMPANY_MSFT = UUID("00000000-0000-4000-a000-000000000002")
|
||||
COMPANY_JPM = UUID("00000000-0000-4000-a000-000000000003")
|
||||
COMPANY_JNJ = UUID("00000000-0000-4000-a000-000000000004")
|
||||
COMPANY_XOM = UUID("00000000-0000-4000-a000-000000000005")
|
||||
|
||||
# Sources (one per company)
|
||||
SOURCE_AAPL = UUID("00000000-0000-4000-b000-000000000001")
|
||||
SOURCE_MSFT = UUID("00000000-0000-4000-b000-000000000002")
|
||||
SOURCE_JPM = UUID("00000000-0000-4000-b000-000000000003")
|
||||
SOURCE_JNJ = UUID("00000000-0000-4000-b000-000000000004")
|
||||
SOURCE_XOM = UUID("00000000-0000-4000-b000-000000000005")
|
||||
|
||||
# Company aliases
|
||||
ALIAS_AAPL = UUID("00000000-0000-4000-b100-000000000001")
|
||||
ALIAS_MSFT = UUID("00000000-0000-4000-b100-000000000002")
|
||||
ALIAS_JPM = UUID("00000000-0000-4000-b100-000000000003")
|
||||
ALIAS_JNJ = UUID("00000000-0000-4000-b100-000000000004")
|
||||
ALIAS_XOM = UUID("00000000-0000-4000-b100-000000000005")
|
||||
|
||||
# Competitor relationships
|
||||
COMPETITOR_REL_1 = UUID("00000000-0000-4000-b200-000000000001")
|
||||
COMPETITOR_REL_2 = UUID("00000000-0000-4000-b200-000000000002")
|
||||
|
||||
# Documents (10)
|
||||
DOC_01 = UUID("00000000-0000-4000-c000-000000000001")
|
||||
DOC_02 = UUID("00000000-0000-4000-c000-000000000002")
|
||||
DOC_03 = UUID("00000000-0000-4000-c000-000000000003")
|
||||
DOC_04 = UUID("00000000-0000-4000-c000-000000000004")
|
||||
DOC_05 = UUID("00000000-0000-4000-c000-000000000005")
|
||||
DOC_06 = UUID("00000000-0000-4000-c000-000000000006")
|
||||
DOC_07 = UUID("00000000-0000-4000-c000-000000000007")
|
||||
DOC_08 = UUID("00000000-0000-4000-c000-000000000008")
|
||||
DOC_09 = UUID("00000000-0000-4000-c000-000000000009")
|
||||
DOC_10 = UUID("00000000-0000-4000-c000-000000000010")
|
||||
|
||||
# Document intelligence (one per document)
|
||||
INTEL_01 = UUID("00000000-0000-4000-c100-000000000001")
|
||||
INTEL_02 = UUID("00000000-0000-4000-c100-000000000002")
|
||||
INTEL_03 = UUID("00000000-0000-4000-c100-000000000003")
|
||||
INTEL_04 = UUID("00000000-0000-4000-c100-000000000004")
|
||||
INTEL_05 = UUID("00000000-0000-4000-c100-000000000005")
|
||||
INTEL_06 = UUID("00000000-0000-4000-c100-000000000006")
|
||||
INTEL_07 = UUID("00000000-0000-4000-c100-000000000007")
|
||||
INTEL_08 = UUID("00000000-0000-4000-c100-000000000008")
|
||||
INTEL_09 = UUID("00000000-0000-4000-c100-000000000009")
|
||||
INTEL_10 = UUID("00000000-0000-4000-c100-000000000010")
|
||||
|
||||
# Document impact records
|
||||
IMPACT_01 = UUID("00000000-0000-4000-c200-000000000001")
|
||||
IMPACT_02 = UUID("00000000-0000-4000-c200-000000000002")
|
||||
IMPACT_03 = UUID("00000000-0000-4000-c200-000000000003")
|
||||
IMPACT_04 = UUID("00000000-0000-4000-c200-000000000004")
|
||||
IMPACT_05 = UUID("00000000-0000-4000-c200-000000000005")
|
||||
IMPACT_06 = UUID("00000000-0000-4000-c200-000000000006")
|
||||
IMPACT_07 = UUID("00000000-0000-4000-c200-000000000007")
|
||||
IMPACT_08 = UUID("00000000-0000-4000-c200-000000000008")
|
||||
IMPACT_09 = UUID("00000000-0000-4000-c200-000000000009")
|
||||
IMPACT_10 = UUID("00000000-0000-4000-c200-000000000010")
|
||||
|
||||
# Document company mentions
|
||||
MENTION_01 = UUID("00000000-0000-4000-c300-000000000001")
|
||||
MENTION_02 = UUID("00000000-0000-4000-c300-000000000002")
|
||||
MENTION_03 = UUID("00000000-0000-4000-c300-000000000003")
|
||||
MENTION_04 = UUID("00000000-0000-4000-c300-000000000004")
|
||||
MENTION_05 = UUID("00000000-0000-4000-c300-000000000005")
|
||||
MENTION_06 = UUID("00000000-0000-4000-c300-000000000006")
|
||||
MENTION_07 = UUID("00000000-0000-4000-c300-000000000007")
|
||||
MENTION_08 = UUID("00000000-0000-4000-c300-000000000008")
|
||||
MENTION_09 = UUID("00000000-0000-4000-c300-000000000009")
|
||||
MENTION_10 = UUID("00000000-0000-4000-c300-000000000010")
|
||||
|
||||
# Trend windows (5)
|
||||
TREND_01 = UUID("00000000-0000-4000-d000-000000000001")
|
||||
TREND_02 = UUID("00000000-0000-4000-d000-000000000002")
|
||||
TREND_03 = UUID("00000000-0000-4000-d000-000000000003")
|
||||
TREND_04 = UUID("00000000-0000-4000-d000-000000000004")
|
||||
TREND_05 = UUID("00000000-0000-4000-d000-000000000005")
|
||||
|
||||
# Trend projections (one per trend)
|
||||
PROJECTION_01 = UUID("00000000-0000-4000-d100-000000000001")
|
||||
PROJECTION_02 = UUID("00000000-0000-4000-d100-000000000002")
|
||||
PROJECTION_03 = UUID("00000000-0000-4000-d100-000000000003")
|
||||
PROJECTION_04 = UUID("00000000-0000-4000-d100-000000000004")
|
||||
PROJECTION_05 = UUID("00000000-0000-4000-d100-000000000005")
|
||||
|
||||
# Recommendations (5)
|
||||
REC_01 = UUID("00000000-0000-4000-e000-000000000001")
|
||||
REC_02 = UUID("00000000-0000-4000-e000-000000000002")
|
||||
REC_03 = UUID("00000000-0000-4000-e000-000000000003")
|
||||
REC_04 = UUID("00000000-0000-4000-e000-000000000004")
|
||||
REC_05 = UUID("00000000-0000-4000-e000-000000000005")
|
||||
|
||||
# Recommendation evidence (one per recommendation)
|
||||
REC_EV_01 = UUID("00000000-0000-4000-e100-000000000001")
|
||||
REC_EV_02 = UUID("00000000-0000-4000-e100-000000000002")
|
||||
REC_EV_03 = UUID("00000000-0000-4000-e100-000000000003")
|
||||
REC_EV_04 = UUID("00000000-0000-4000-e100-000000000004")
|
||||
REC_EV_05 = UUID("00000000-0000-4000-e100-000000000005")
|
||||
|
||||
# Risk evaluations
|
||||
RISK_EVAL_01 = UUID("00000000-0000-4000-e200-000000000001")
|
||||
|
||||
# Broker account
|
||||
BROKER_ACCT_01 = UUID("00000000-0000-4000-f000-000000000001")
|
||||
|
||||
# Orders (3)
|
||||
ORDER_01 = UUID("00000000-0000-4000-f100-000000000001")
|
||||
ORDER_02 = UUID("00000000-0000-4000-f100-000000000002")
|
||||
ORDER_03 = UUID("00000000-0000-4000-f100-000000000003")
|
||||
|
||||
# Order events
|
||||
ORDER_EVT_01 = UUID("00000000-0000-4000-f200-000000000001")
|
||||
ORDER_EVT_02 = UUID("00000000-0000-4000-f200-000000000002")
|
||||
ORDER_EVT_03 = UUID("00000000-0000-4000-f200-000000000003")
|
||||
ORDER_EVT_04 = UUID("00000000-0000-4000-f200-000000000004")
|
||||
ORDER_EVT_05 = UUID("00000000-0000-4000-f200-000000000005")
|
||||
|
||||
# Positions (2)
|
||||
POSITION_01 = UUID("00000000-0000-4000-f300-000000000001")
|
||||
POSITION_02 = UUID("00000000-0000-4000-f300-000000000002")
|
||||
|
||||
# Global events (2)
|
||||
GLOBAL_EVT_01 = UUID("00000000-0000-4000-a100-000000000001")
|
||||
GLOBAL_EVT_02 = UUID("00000000-0000-4000-a100-000000000002")
|
||||
|
||||
# Macro impact records (4 — 2 per global event, across multiple companies)
|
||||
MACRO_IMPACT_01 = UUID("00000000-0000-4000-a200-000000000001")
|
||||
MACRO_IMPACT_02 = UUID("00000000-0000-4000-a200-000000000002")
|
||||
MACRO_IMPACT_03 = UUID("00000000-0000-4000-a200-000000000003")
|
||||
MACRO_IMPACT_04 = UUID("00000000-0000-4000-a200-000000000004")
|
||||
|
||||
# Exposure profiles (2)
|
||||
EXPOSURE_01 = UUID("00000000-0000-4000-a300-000000000001")
|
||||
EXPOSURE_02 = UUID("00000000-0000-4000-a300-000000000002")
|
||||
|
||||
# Competitive signal records (2)
|
||||
COMP_SIGNAL_01 = UUID("00000000-0000-4000-a400-000000000001")
|
||||
COMP_SIGNAL_02 = UUID("00000000-0000-4000-a400-000000000002")
|
||||
|
||||
# Trading decisions
|
||||
TRADING_DECISION_01 = UUID("00000000-0000-4000-a500-000000000001")
|
||||
|
||||
# Portfolio snapshot
|
||||
PORTFOLIO_SNAP_01 = UUID("00000000-0000-4000-a600-000000000001")
|
||||
|
||||
# AI agents (use slugs to match migration 026 seed rows)
|
||||
AGENT_EXTRACTOR = UUID("00000000-0000-4000-a700-000000000001")
|
||||
AGENT_CLASSIFIER = UUID("00000000-0000-4000-a700-000000000002")
|
||||
AGENT_THESIS = UUID("00000000-0000-4000-a700-000000000003")
|
||||
|
||||
# Agent variants (1 per agent)
|
||||
VARIANT_EXTRACTOR = UUID("00000000-0000-4000-a800-000000000001")
|
||||
VARIANT_CLASSIFIER = UUID("00000000-0000-4000-a800-000000000002")
|
||||
VARIANT_THESIS = UUID("00000000-0000-4000-a800-000000000003")
|
||||
|
||||
# Agent performance log entries
|
||||
PERF_LOG_01 = UUID("00000000-0000-4000-a900-000000000001")
|
||||
PERF_LOG_02 = UUID("00000000-0000-4000-a900-000000000002")
|
||||
PERF_LOG_03 = UUID("00000000-0000-4000-a900-000000000003")
|
||||
|
||||
# Risk config
|
||||
RISK_CONFIG_01 = UUID("00000000-0000-4000-aa00-000000000001")
|
||||
|
||||
# Audit events
|
||||
AUDIT_01 = UUID("00000000-0000-4000-ab00-000000000001")
|
||||
AUDIT_02 = UUID("00000000-0000-4000-ab00-000000000002")
|
||||
AUDIT_03 = UUID("00000000-0000-4000-ab00-000000000003")
|
||||
|
||||
# ── Exported lookup dicts for test imports ────────────────────
|
||||
|
||||
SEED_COMPANY_IDS = {
|
||||
"AAPL": str(COMPANY_AAPL),
|
||||
"MSFT": str(COMPANY_MSFT),
|
||||
"JPM": str(COMPANY_JPM),
|
||||
"JNJ": str(COMPANY_JNJ),
|
||||
"XOM": str(COMPANY_XOM),
|
||||
}
|
||||
|
||||
SEED_DOCUMENT_IDS = {
|
||||
f"DOC_{i:02d}": str(uid)
|
||||
for i, uid in enumerate(
|
||||
[DOC_01, DOC_02, DOC_03, DOC_04, DOC_05,
|
||||
DOC_06, DOC_07, DOC_08, DOC_09, DOC_10],
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
|
||||
SEED_TREND_IDS = {
|
||||
f"TREND_{i:02d}": str(uid)
|
||||
for i, uid in enumerate(
|
||||
[TREND_01, TREND_02, TREND_03, TREND_04, TREND_05], start=1
|
||||
)
|
||||
}
|
||||
|
||||
SEED_RECOMMENDATION_IDS = {
|
||||
f"REC_{i:02d}": str(uid)
|
||||
for i, uid in enumerate(
|
||||
[REC_01, REC_02, REC_03, REC_04, REC_05], start=1
|
||||
)
|
||||
}
|
||||
|
||||
SEED_ORDER_IDS = {
|
||||
f"ORDER_{i:02d}": str(uid)
|
||||
for i, uid in enumerate([ORDER_01, ORDER_02, ORDER_03], start=1)
|
||||
}
|
||||
|
||||
SEED_POSITION_IDS = {
|
||||
f"POS_{i:02d}": str(uid)
|
||||
for i, uid in enumerate([POSITION_01, POSITION_02], start=1)
|
||||
}
|
||||
|
||||
SEED_GLOBAL_EVENT_IDS = {
|
||||
f"EVT_{i:02d}": str(uid)
|
||||
for i, uid in enumerate([GLOBAL_EVT_01, GLOBAL_EVT_02], start=1)
|
||||
}
|
||||
|
||||
SEED_AGENT_IDS = {
|
||||
"extractor": str(AGENT_EXTRACTOR),
|
||||
"classifier": str(AGENT_CLASSIFIER),
|
||||
"thesis": str(AGENT_THESIS),
|
||||
}
|
||||
|
||||
SEED_VARIANT_IDS = {
|
||||
"extractor": str(VARIANT_EXTRACTOR),
|
||||
"classifier": str(VARIANT_CLASSIFIER),
|
||||
"thesis": str(VARIANT_THESIS),
|
||||
}
|
||||
|
||||
SEED_BROKER_ACCOUNT_ID = str(BROKER_ACCT_01)
|
||||
SEED_TRADING_DECISION_ID = str(TRADING_DECISION_01)
|
||||
SEED_PORTFOLIO_SNAPSHOT_ID = str(PORTFOLIO_SNAP_01)
|
||||
SEED_RISK_CONFIG_ID = str(RISK_CONFIG_01)
|
||||
|
||||
|
||||
# ── Seed function ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
"""Populate the database with deterministic test data."""
|
||||
dsn = (
|
||||
f"postgresql://{os.environ['POSTGRES_USER']}"
|
||||
f":{os.environ['POSTGRES_PASSWORD']}"
|
||||
f"@{os.environ['POSTGRES_HOST']}"
|
||||
f":{os.environ.get('POSTGRES_PORT', '5432')}"
|
||||
f"/{os.environ['POSTGRES_DB']}"
|
||||
)
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
await _seed_companies(conn)
|
||||
await _seed_sources(conn)
|
||||
await _seed_aliases(conn)
|
||||
await _seed_competitor_relationships(conn)
|
||||
await _seed_documents(conn)
|
||||
await _seed_document_mentions(conn)
|
||||
await _seed_document_intelligence(conn)
|
||||
await _seed_document_impact_records(conn)
|
||||
await _seed_trend_windows(conn)
|
||||
await _seed_trend_projections(conn)
|
||||
await _seed_recommendations(conn)
|
||||
await _seed_recommendation_evidence(conn)
|
||||
await _seed_risk_evaluations(conn)
|
||||
await _seed_broker_accounts(conn)
|
||||
await _seed_orders(conn)
|
||||
await _seed_order_events(conn)
|
||||
await _seed_positions(conn)
|
||||
await _seed_global_events(conn)
|
||||
await _seed_macro_impact_records(conn)
|
||||
await _seed_exposure_profiles(conn)
|
||||
await _seed_competitive_signals(conn)
|
||||
await _seed_trading_engine_config(conn)
|
||||
await _seed_trading_decisions(conn)
|
||||
await _seed_portfolio_snapshots(conn)
|
||||
await _seed_ai_agents(conn)
|
||||
await _seed_agent_variants(conn)
|
||||
await _seed_agent_performance_log(conn)
|
||||
await _seed_risk_configs(conn)
|
||||
await _seed_audit_events(conn)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
# ── Companies ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_companies(conn: asyncpg.Connection) -> None:
|
||||
companies = [
|
||||
(COMPANY_AAPL, "AAPL", "Apple Inc", "NASDAQ", "Technology", "Consumer Electronics", "mega", True, BASE_TS),
|
||||
(COMPANY_MSFT, "MSFT", "Microsoft Corp", "NASDAQ", "Technology", "Software - Infrastructure", "mega", True, BASE_TS),
|
||||
(COMPANY_JPM, "JPM", "JPMorgan Chase & Co", "NYSE", "Financial Services", "Banks - Diversified", "mega", True, BASE_TS),
|
||||
(COMPANY_JNJ, "JNJ", "Johnson & Johnson", "NYSE", "Healthcare", "Drug Manufacturers", "mega", True, BASE_TS),
|
||||
(COMPANY_XOM, "XOM", "Exxon Mobil Corp", "NYSE", "Energy", "Oil & Gas Integrated", "mega", True, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO companies (id, ticker, legal_name, exchange, sector, industry, market_cap_bucket, active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
companies,
|
||||
)
|
||||
|
||||
|
||||
# ── Sources ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_sources(conn: asyncpg.Connection) -> None:
|
||||
sources = [
|
||||
(SOURCE_AAPL, COMPANY_AAPL, "news", "Polygon News", json.dumps({"provider": "polygon"}), 0.8, True, BASE_TS),
|
||||
(SOURCE_MSFT, COMPANY_MSFT, "news", "Polygon News", json.dumps({"provider": "polygon"}), 0.8, True, BASE_TS),
|
||||
(SOURCE_JPM, COMPANY_JPM, "filing", "SEC EDGAR", json.dumps({"cik": "0000019617"}), 0.95, True, BASE_TS),
|
||||
(SOURCE_JNJ, COMPANY_JNJ, "news", "Polygon News", json.dumps({"provider": "polygon"}), 0.8, True, BASE_TS),
|
||||
(SOURCE_XOM, COMPANY_XOM, "news", "Polygon News", json.dumps({"provider": "polygon"}), 0.8, True, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO sources (id, company_id, source_type, source_name, config, credibility_score, active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
sources,
|
||||
)
|
||||
|
||||
|
||||
# ── Aliases ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_aliases(conn: asyncpg.Connection) -> None:
|
||||
aliases = [
|
||||
(ALIAS_AAPL, COMPANY_AAPL, "Apple", "brand", BASE_TS),
|
||||
(ALIAS_MSFT, COMPANY_MSFT, "Microsoft", "brand", BASE_TS),
|
||||
(ALIAS_JPM, COMPANY_JPM, "JP Morgan", "brand", BASE_TS),
|
||||
(ALIAS_JNJ, COMPANY_JNJ, "J&J", "brand", BASE_TS),
|
||||
(ALIAS_XOM, COMPANY_XOM, "Exxon", "brand", BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO company_aliases (id, company_id, alias, alias_type, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
aliases,
|
||||
)
|
||||
|
||||
|
||||
# ── Competitor Relationships ──────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_competitor_relationships(conn: asyncpg.Connection) -> None:
|
||||
rels = [
|
||||
(COMPETITOR_REL_1, COMPANY_AAPL, COMPANY_MSFT, "direct_rival", 0.85, True, "manual", True, BASE_TS),
|
||||
(COMPETITOR_REL_2, COMPANY_JPM, COMPANY_JNJ, "same_sector", 0.3, True, "inferred", True, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO competitor_relationships
|
||||
(id, company_a_id, company_b_id, relationship_type, strength, bidirectional, source, active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
rels,
|
||||
)
|
||||
|
||||
|
||||
# ── Documents (10) ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_documents(conn: asyncpg.Connection) -> None:
|
||||
# Mix: 6 news, 2 filings, 2 macro_event
|
||||
docs = [
|
||||
(DOC_01, "news", "news", "Reuters", "https://example.com/aapl-1", "Apple Q4 Earnings Beat", BASE_TS - timedelta(days=5), "hash_doc_01", "ingested", BASE_TS),
|
||||
(DOC_02, "news", "news", "Bloomberg", "https://example.com/msft-1", "Microsoft Cloud Revenue Surges", BASE_TS - timedelta(days=4), "hash_doc_02", "processed", BASE_TS),
|
||||
(DOC_03, "filing", "filing", "SEC EDGAR", "https://sec.gov/jpm-10k", "JPM Annual Report 10-K", BASE_TS - timedelta(days=10), "hash_doc_03", "processed", BASE_TS),
|
||||
(DOC_04, "news", "news", "CNBC", "https://example.com/jnj-1", "J&J Drug Trial Results", BASE_TS - timedelta(days=3), "hash_doc_04", "processed", BASE_TS),
|
||||
(DOC_05, "news", "news", "Reuters", "https://example.com/xom-1", "Exxon Oil Production Update", BASE_TS - timedelta(days=2), "hash_doc_05", "processed", BASE_TS),
|
||||
(DOC_06, "news", "news", "WSJ", "https://example.com/aapl-2", "Apple Vision Pro Sales", BASE_TS - timedelta(days=1), "hash_doc_06", "processed", BASE_TS),
|
||||
(DOC_07, "filing", "filing", "SEC EDGAR", "https://sec.gov/msft-10q", "MSFT Quarterly Filing 10-Q", BASE_TS - timedelta(days=8), "hash_doc_07", "processed", BASE_TS),
|
||||
(DOC_08, "macro_event", "news", "Reuters", "https://example.com/fed-1", "Fed Rate Decision January 2025", BASE_TS - timedelta(days=6), "hash_doc_08", "processed", BASE_TS),
|
||||
(DOC_09, "macro_event", "news", "Bloomberg", "https://example.com/trade-1", "US-China Trade Tensions Escalate", BASE_TS - timedelta(days=7), "hash_doc_09", "processed", BASE_TS),
|
||||
(DOC_10, "news", "news", "MarketWatch", "https://example.com/jpm-2", "JPM Investment Banking Revenue", BASE_TS - timedelta(days=1), "hash_doc_10", "processed", BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO documents
|
||||
(id, document_type, source_type, publisher, url, title, published_at, content_hash, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
docs,
|
||||
)
|
||||
|
||||
|
||||
# ── Document Company Mentions ─────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_document_mentions(conn: asyncpg.Connection) -> None:
|
||||
mentions = [
|
||||
(MENTION_01, DOC_01, COMPANY_AAPL, "AAPL", "direct", 0.95, BASE_TS),
|
||||
(MENTION_02, DOC_02, COMPANY_MSFT, "MSFT", "direct", 0.95, BASE_TS),
|
||||
(MENTION_03, DOC_03, COMPANY_JPM, "JPM", "direct", 0.90, BASE_TS),
|
||||
(MENTION_04, DOC_04, COMPANY_JNJ, "JNJ", "direct", 0.90, BASE_TS),
|
||||
(MENTION_05, DOC_05, COMPANY_XOM, "XOM", "direct", 0.90, BASE_TS),
|
||||
(MENTION_06, DOC_06, COMPANY_AAPL, "AAPL", "direct", 0.95, BASE_TS),
|
||||
(MENTION_07, DOC_07, COMPANY_MSFT, "MSFT", "direct", 0.90, BASE_TS),
|
||||
(MENTION_08, DOC_08, COMPANY_JPM, "JPM", "indirect", 0.60, BASE_TS),
|
||||
(MENTION_09, DOC_09, COMPANY_AAPL, "AAPL", "indirect", 0.50, BASE_TS),
|
||||
(MENTION_10, DOC_10, COMPANY_JPM, "JPM", "direct", 0.90, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO document_company_mentions
|
||||
(id, document_id, company_id, ticker, mention_type, confidence, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
mentions,
|
||||
)
|
||||
|
||||
|
||||
# ── Document Intelligence ─────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_document_intelligence(conn: asyncpg.Connection) -> None:
|
||||
intels = [
|
||||
(INTEL_01, DOC_01, "Apple beats Q4 expectations with strong iPhone sales.", 0.85, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
(INTEL_02, DOC_02, "Microsoft Azure revenue grows 29% year-over-year.", 0.90, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
(INTEL_03, DOC_03, "JPMorgan reports record annual profit driven by investment banking.", 0.88, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
(INTEL_04, DOC_04, "J&J Phase 3 trial shows positive results for new cancer drug.", 0.82, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
(INTEL_05, DOC_05, "Exxon increases Permian Basin output by 15%.", 0.78, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
(INTEL_06, DOC_06, "Apple Vision Pro sees moderate adoption in enterprise segment.", 0.75, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
(INTEL_07, DOC_07, "Microsoft quarterly filing shows strong cloud and AI growth.", 0.87, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
(INTEL_08, DOC_08, "Fed holds rates steady, signals potential cuts in Q2.", 0.92, "ollama", "qwen3.5:9b", "event-classification-v1", "1.0.0", "valid", BASE_TS),
|
||||
(INTEL_09, DOC_09, "US-China trade tensions rise with new tariff proposals.", 0.88, "ollama", "qwen3.5:9b", "event-classification-v1", "1.0.0", "valid", BASE_TS),
|
||||
(INTEL_10, DOC_10, "JPM investment banking fees up 20% in Q4.", 0.80, "ollama", "qwen3.5:9b", "document-intel-v2", "2.0.0", "valid", BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO document_intelligence
|
||||
(id, document_id, summary, confidence, model_provider, model_name, prompt_version, schema_version, validation_status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
intels,
|
||||
)
|
||||
|
||||
|
||||
# ── Document Impact Records ───────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_document_impact_records(conn: asyncpg.Connection) -> None:
|
||||
impacts = [
|
||||
(IMPACT_01, INTEL_01, COMPANY_AAPL, "AAPL", 0.9, "positive", 0.8, "short_term", "earnings", json.dumps(["Strong iPhone sales"]), json.dumps([]), json.dumps(["beat expectations"]), BASE_TS),
|
||||
(IMPACT_02, INTEL_02, COMPANY_MSFT, "MSFT", 0.9, "positive", 0.85, "medium_term", "growth", json.dumps(["Azure 29% growth"]), json.dumps([]), json.dumps(["cloud revenue surge"]), BASE_TS),
|
||||
(IMPACT_03, INTEL_03, COMPANY_JPM, "JPM", 0.85, "positive", 0.7, "short_term", "earnings", json.dumps(["Record profit"]), json.dumps(["Rate sensitivity"]), json.dumps(["investment banking"]), BASE_TS),
|
||||
(IMPACT_04, INTEL_04, COMPANY_JNJ, "JNJ", 0.8, "positive", 0.75, "long_term", "product", json.dumps(["Phase 3 success"]), json.dumps(["Regulatory risk"]), json.dumps(["cancer drug trial"]), BASE_TS),
|
||||
(IMPACT_05, INTEL_05, COMPANY_XOM, "XOM", 0.8, "positive", 0.6, "medium_term", "operational", json.dumps(["Production increase"]), json.dumps(["Oil price risk"]), json.dumps(["Permian Basin"]), BASE_TS),
|
||||
(IMPACT_06, INTEL_06, COMPANY_AAPL, "AAPL", 0.7, "neutral", 0.4, "medium_term", "product", json.dumps(["Enterprise adoption"]), json.dumps(["Consumer demand weak"]), json.dumps(["Vision Pro"]), BASE_TS),
|
||||
(IMPACT_07, INTEL_07, COMPANY_MSFT, "MSFT", 0.85, "positive", 0.8, "medium_term", "growth", json.dumps(["AI and cloud growth"]), json.dumps([]), json.dumps(["quarterly filing"]), BASE_TS),
|
||||
(IMPACT_08, INTEL_08, COMPANY_JPM, "JPM", 0.6, "positive", 0.5, "medium_term", "macro", json.dumps(["Rate cut signal"]), json.dumps(["Inflation risk"]), json.dumps(["Fed decision"]), BASE_TS),
|
||||
(IMPACT_09, INTEL_09, COMPANY_AAPL, "AAPL", 0.5, "negative", -0.3, "short_term", "geopolitical", json.dumps(["Supply chain risk"]), json.dumps(["Tariff impact"]), json.dumps(["trade tensions"]), BASE_TS),
|
||||
(IMPACT_10, INTEL_10, COMPANY_JPM, "JPM", 0.85, "positive", 0.65, "short_term", "earnings", json.dumps(["IB fees up 20%"]), json.dumps([]), json.dumps(["investment banking"]), BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO document_impact_records
|
||||
(id, intelligence_id, company_id, ticker, relevance, sentiment, impact_score,
|
||||
impact_horizon, catalyst_type, key_facts, risks, evidence_spans, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12::jsonb, $13)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
impacts,
|
||||
)
|
||||
|
||||
|
||||
# ── Trend Windows (5) ────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_trend_windows(conn: asyncpg.Connection) -> None:
|
||||
# entity_id is VARCHAR, not UUID
|
||||
trends = [
|
||||
(TREND_01, "company", str(COMPANY_AAPL), "7d", "bullish", 0.72, 0.80,
|
||||
json.dumps([{"fact": "Strong iPhone sales", "weight": 0.9}]),
|
||||
json.dumps([{"fact": "Trade tensions", "weight": 0.3}]),
|
||||
json.dumps(["earnings", "product"]), json.dumps(["tariff_risk"]),
|
||||
0.15, json.dumps({"market_phase": "expansion"}), BASE_TS, BASE_TS),
|
||||
(TREND_02, "company", str(COMPANY_MSFT), "7d", "bullish", 0.85, 0.88,
|
||||
json.dumps([{"fact": "Azure growth 29%", "weight": 0.95}]),
|
||||
json.dumps([]),
|
||||
json.dumps(["growth", "cloud"]), json.dumps([]),
|
||||
0.05, json.dumps({"market_phase": "expansion"}), BASE_TS, BASE_TS),
|
||||
(TREND_03, "company", str(COMPANY_JPM), "7d", "bullish", 0.60, 0.70,
|
||||
json.dumps([{"fact": "Record profit", "weight": 0.8}]),
|
||||
json.dumps([{"fact": "Rate sensitivity", "weight": 0.4}]),
|
||||
json.dumps(["earnings"]), json.dumps(["interest_rate_risk"]),
|
||||
0.25, json.dumps({"market_phase": "stable"}), BASE_TS, BASE_TS),
|
||||
(TREND_04, "company", str(COMPANY_JNJ), "30d", "bullish", 0.55, 0.65,
|
||||
json.dumps([{"fact": "Drug trial success", "weight": 0.75}]),
|
||||
json.dumps([{"fact": "Regulatory risk", "weight": 0.3}]),
|
||||
json.dumps(["product"]), json.dumps(["regulatory"]),
|
||||
0.20, json.dumps({"market_phase": "stable"}), BASE_TS, BASE_TS),
|
||||
(TREND_05, "company", str(COMPANY_XOM), "7d", "mixed", 0.40, 0.55,
|
||||
json.dumps([{"fact": "Production increase", "weight": 0.6}]),
|
||||
json.dumps([{"fact": "Oil price volatility", "weight": 0.5}]),
|
||||
json.dumps(["operational"]), json.dumps(["commodity_risk"]),
|
||||
0.35, json.dumps({"market_phase": "uncertain"}), BASE_TS, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO trend_windows
|
||||
(id, entity_type, entity_id, "window", trend_direction, trend_strength, confidence,
|
||||
top_supporting_evidence, top_opposing_evidence, dominant_catalysts, material_risks,
|
||||
contradiction_score, market_context, generated_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
|
||||
$12, $13::jsonb, $14, $15)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
trends,
|
||||
)
|
||||
|
||||
|
||||
# ── Trend Projections ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_trend_projections(conn: asyncpg.Connection) -> None:
|
||||
projections = [
|
||||
(PROJECTION_01, TREND_01, "bullish", 0.70, 0.75, "7d", json.dumps(["earnings_momentum"]), 0.10, False, BASE_TS),
|
||||
(PROJECTION_02, TREND_02, "bullish", 0.88, 0.85, "7d", json.dumps(["cloud_growth"]), 0.05, False, BASE_TS),
|
||||
(PROJECTION_03, TREND_03, "bullish", 0.55, 0.60, "7d", json.dumps(["banking_recovery"]), 0.20, False, BASE_TS),
|
||||
(PROJECTION_04, TREND_04, "bullish", 0.50, 0.58, "30d", json.dumps(["drug_pipeline"]), 0.15, False, BASE_TS),
|
||||
(PROJECTION_05, TREND_05, "bearish", 0.45, 0.50, "7d", json.dumps(["oil_price_decline"]), 0.30, True, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO trend_projections
|
||||
(id, trend_window_id, projected_direction, projected_strength, projected_confidence,
|
||||
projection_horizon, driving_factors, macro_contribution_pct, diverges_from_current, computed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
projections,
|
||||
)
|
||||
|
||||
|
||||
# ── Recommendations (5) ──────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_recommendations(conn: asyncpg.Connection) -> None:
|
||||
recs = [
|
||||
(REC_01, "AAPL", COMPANY_AAPL, "buy", "autonomous", 0.80, "7d", "Strong earnings momentum with iPhone sales beat.", json.dumps(["Trade war escalation"]), 0.03, 0.01, "v1.0", BASE_TS, BASE_TS),
|
||||
(REC_02, "MSFT", COMPANY_MSFT, "buy", "autonomous", 0.88, "14d", "Azure cloud growth accelerating with AI tailwinds.", json.dumps(["Cloud competition"]), 0.04, 0.008, "v1.0", BASE_TS, BASE_TS),
|
||||
(REC_03, "JPM", COMPANY_JPM, "watch", "informational", 0.65, "7d", "Mixed signals: strong IB but rate uncertainty.", json.dumps(["Rate hike"]), 0.02, 0.005, "v1.0", BASE_TS, BASE_TS),
|
||||
(REC_04, "JNJ", COMPANY_JNJ, "buy", "paper", 0.70, "30d", "Positive drug trial results support long-term thesis.", json.dumps(["FDA rejection"]), 0.025, 0.007, "v1.0", BASE_TS, BASE_TS),
|
||||
(REC_05, "XOM", COMPANY_XOM, "sell", "informational", 0.55, "7d", "Oil price headwinds outweigh production gains.", json.dumps(["Oil price spike"]), 0.02, 0.01, "v1.0", BASE_TS, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO recommendations
|
||||
(id, ticker, company_id, action, mode, confidence, time_horizon, thesis,
|
||||
invalidation_conditions, portfolio_pct, max_loss_pct, model_version, generated_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
recs,
|
||||
)
|
||||
|
||||
|
||||
# ── Recommendation Evidence ───────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_recommendation_evidence(conn: asyncpg.Connection) -> None:
|
||||
evidence = [
|
||||
(REC_EV_01, REC_01, DOC_01, INTEL_01, "supporting", 0.9, BASE_TS),
|
||||
(REC_EV_02, REC_02, DOC_02, INTEL_02, "supporting", 0.95, BASE_TS),
|
||||
(REC_EV_03, REC_03, DOC_03, INTEL_03, "supporting", 0.7, BASE_TS),
|
||||
(REC_EV_04, REC_04, DOC_04, INTEL_04, "supporting", 0.8, BASE_TS),
|
||||
(REC_EV_05, REC_05, DOC_05, INTEL_05, "opposing", 0.6, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO recommendation_evidence
|
||||
(id, recommendation_id, document_id, intelligence_id, evidence_type, weight, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
evidence,
|
||||
)
|
||||
|
||||
|
||||
# ── Risk Evaluations ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_risk_evaluations(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(
|
||||
"""INSERT INTO risk_evaluations
|
||||
(id, recommendation_id, eligible, allowed_mode, rejection_reasons, risk_checks, evaluated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
RISK_EVAL_01, REC_01, True, "autonomous", json.dumps([]),
|
||||
json.dumps({"portfolio_heat": "pass", "sector_exposure": "pass", "daily_loss": "pass"}),
|
||||
BASE_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── Broker Accounts ───────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_broker_accounts(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(
|
||||
"""INSERT INTO broker_accounts (id, provider, account_id, mode, config, active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
BROKER_ACCT_01, "alpaca", "PAPER-001", "paper",
|
||||
json.dumps({"base_url": "https://paper-api.alpaca.markets"}),
|
||||
True, BASE_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── Orders (3: filled, pending, cancelled) ───────────────────
|
||||
|
||||
|
||||
async def _seed_orders(conn: asyncpg.Connection) -> None:
|
||||
orders = [
|
||||
# Filled order
|
||||
(ORDER_01, REC_01, BROKER_ACCT_01, "AAPL", "buy", "market", 10, None, None,
|
||||
"filled", "inttest-order-001", "broker-ord-001",
|
||||
json.dumps({"reason": "earnings_momentum"}),
|
||||
BASE_TS, BASE_TS + timedelta(seconds=5), BASE_TS + timedelta(seconds=30),
|
||||
None, None, None, 185.50, 10, BASE_TS, BASE_TS),
|
||||
# Pending order
|
||||
(ORDER_02, REC_02, BROKER_ACCT_01, "MSFT", "buy", "limit", 5, 410.00, None,
|
||||
"pending", "inttest-order-002", None,
|
||||
json.dumps({"reason": "cloud_growth"}),
|
||||
BASE_TS, None, None,
|
||||
None, None, None, None, None, BASE_TS, BASE_TS),
|
||||
# Cancelled order
|
||||
(ORDER_03, REC_05, BROKER_ACCT_01, "XOM", "sell", "market", 20, None, None,
|
||||
"cancelled", "inttest-order-003", "broker-ord-003",
|
||||
json.dumps({"reason": "oil_headwinds"}),
|
||||
BASE_TS, BASE_TS + timedelta(seconds=3), None,
|
||||
BASE_TS + timedelta(minutes=5), None, None, None, None, BASE_TS, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO orders
|
||||
(id, recommendation_id, broker_account_id, ticker, side, order_type, quantity,
|
||||
limit_price, stop_price, status, idempotency_key, broker_order_id, decision_trace,
|
||||
submitted_at, acknowledged_at, filled_at, cancelled_at, rejected_at, rejection_reason,
|
||||
fill_price, fill_quantity, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
orders,
|
||||
)
|
||||
|
||||
|
||||
# ── Order Events ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_order_events(conn: asyncpg.Connection) -> None:
|
||||
events = [
|
||||
(ORDER_EVT_01, ORDER_01, "submitted", json.dumps({"qty": 10}), BASE_TS, BASE_TS),
|
||||
(ORDER_EVT_02, ORDER_01, "acknowledged", json.dumps({"broker_id": "broker-ord-001"}), BASE_TS + timedelta(seconds=5), BASE_TS + timedelta(seconds=5)),
|
||||
(ORDER_EVT_03, ORDER_01, "filled", json.dumps({"fill_price": 185.50, "fill_qty": 10}), BASE_TS + timedelta(seconds=30), BASE_TS + timedelta(seconds=30)),
|
||||
(ORDER_EVT_04, ORDER_02, "submitted", json.dumps({"qty": 5, "limit": 410.00}), BASE_TS, BASE_TS),
|
||||
(ORDER_EVT_05, ORDER_03, "cancelled", json.dumps({"reason": "user_request"}), BASE_TS + timedelta(minutes=5), BASE_TS + timedelta(minutes=5)),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO order_events (id, order_id, event_type, data, broker_timestamp, created_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
events,
|
||||
)
|
||||
|
||||
|
||||
# ── Positions (2) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_positions(conn: asyncpg.Connection) -> None:
|
||||
positions = [
|
||||
(POSITION_01, BROKER_ACCT_01, "AAPL", 10, 185.50, 192.30, 68.00, 0.0, BASE_TS),
|
||||
(POSITION_02, BROKER_ACCT_01, "MSFT", 15, 405.00, 412.75, 116.25, 50.00, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO positions
|
||||
(id, broker_account_id, ticker, quantity, avg_entry_price, current_price,
|
||||
unrealized_pnl, realized_pnl, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
positions,
|
||||
)
|
||||
|
||||
|
||||
# ── Global Events (2) ────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_global_events(conn: asyncpg.Connection) -> None:
|
||||
events = [
|
||||
(GLOBAL_EVT_01, ["interest_rate_decision"], "medium",
|
||||
["North America"], ["Financial Services", "Real Estate"],
|
||||
[], "Federal Reserve holds rates steady, signals potential cuts in Q2 2025.",
|
||||
json.dumps(["Fed holds rates", "Potential Q2 cuts", "Inflation moderating"]),
|
||||
"weeks", 0.88, DOC_08, "ollama", "qwen3.5:9b", "event-classification-v1", "1.0.0", BASE_TS),
|
||||
(GLOBAL_EVT_02, ["trade_war", "tariff"], "high",
|
||||
["North America", "East Asia"], ["Technology", "Consumer Electronics"],
|
||||
[], "US-China trade tensions escalate with new tariff proposals on tech imports.",
|
||||
json.dumps(["New tariffs proposed", "Tech sector targeted", "Supply chain disruption"]),
|
||||
"months", 0.82, DOC_09, "ollama", "qwen3.5:9b", "event-classification-v1", "1.0.0", BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO global_events
|
||||
(id, event_types, severity, affected_regions, affected_sectors, affected_commodities,
|
||||
summary, key_facts, estimated_duration, confidence, source_document_id,
|
||||
model_provider, model_name, prompt_version, schema_version, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
events,
|
||||
)
|
||||
|
||||
|
||||
# ── Macro Impact Records (4) ─────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_macro_impact_records(conn: asyncpg.Connection) -> None:
|
||||
records = [
|
||||
# Fed rate decision impacts JPM and JNJ
|
||||
(MACRO_IMPACT_01, GLOBAL_EVT_01, COMPANY_JPM, "JPM", 0.75, "positive",
|
||||
json.dumps(["Rate-sensitive banking sector benefits"]), 0.80, BASE_TS),
|
||||
(MACRO_IMPACT_02, GLOBAL_EVT_01, COMPANY_JNJ, "JNJ", 0.25, "neutral",
|
||||
json.dumps(["Healthcare less rate-sensitive"]), 0.70, BASE_TS),
|
||||
# Trade tensions impact AAPL and MSFT
|
||||
(MACRO_IMPACT_03, GLOBAL_EVT_02, COMPANY_AAPL, "AAPL", -0.60, "negative",
|
||||
json.dumps(["China manufacturing exposure", "Tariff on electronics"]), 0.85, BASE_TS),
|
||||
(MACRO_IMPACT_04, GLOBAL_EVT_02, COMPANY_MSFT, "MSFT", -0.30, "negative",
|
||||
json.dumps(["Cloud infrastructure less exposed"]), 0.75, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO macro_impact_records
|
||||
(id, event_id, company_id, ticker, macro_impact_score, impact_direction,
|
||||
contributing_factors, confidence, computed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
records,
|
||||
)
|
||||
|
||||
|
||||
# ── Exposure Profiles (2) ────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_exposure_profiles(conn: asyncpg.Connection) -> None:
|
||||
profiles = [
|
||||
(EXPOSURE_01, COMPANY_AAPL,
|
||||
json.dumps({"North America": 0.45, "Europe": 0.25, "China": 0.20, "Rest of Asia": 0.10}),
|
||||
["China", "Taiwan", "India"], ["semiconductors", "rare_earth"],
|
||||
["US", "EU", "China"], "global_leader", 0.55, "manual", 1.0, 1, True, BASE_TS, BASE_TS),
|
||||
(EXPOSURE_02, COMPANY_JPM,
|
||||
json.dumps({"North America": 0.70, "Europe": 0.20, "Asia": 0.10}),
|
||||
["North America", "Europe"], [],
|
||||
["US", "UK", "EU"], "global_leader", 0.30, "manual", 1.0, 1, True, BASE_TS, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO exposure_profiles
|
||||
(id, company_id, geographic_revenue_mix, supply_chain_regions, key_input_commodities,
|
||||
regulatory_jurisdictions, market_position_tier, export_dependency_pct,
|
||||
source, confidence, version, active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
profiles,
|
||||
)
|
||||
|
||||
|
||||
# ── Competitive Signal Records (2) ───────────────────────────
|
||||
|
||||
|
||||
async def _seed_competitive_signals(conn: asyncpg.Connection) -> None:
|
||||
signals = [
|
||||
(COMP_SIGNAL_01, DOC_01, "AAPL", "MSFT", "earnings_beat", 0.75,
|
||||
"positive", 0.6, 0.85, BASE_TS),
|
||||
(COMP_SIGNAL_02, DOC_05, "XOM", "JPM", "production_change", 0.50,
|
||||
"neutral", 0.3, 0.30, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO competitive_signal_records
|
||||
(id, source_document_id, source_ticker, target_ticker, catalyst_type,
|
||||
pattern_confidence, signal_direction, signal_strength, relationship_strength, computed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
signals,
|
||||
)
|
||||
|
||||
|
||||
# ── Trading Engine Config (UPDATE existing row from migration 018) ─
|
||||
|
||||
|
||||
async def _seed_trading_engine_config(conn: asyncpg.Connection) -> None:
|
||||
# Migration 018 inserts a default row. Update it rather than insert.
|
||||
await conn.execute(
|
||||
"""UPDATE trading_engine_config
|
||||
SET enabled = TRUE,
|
||||
paused = FALSE,
|
||||
risk_tier = 'moderate',
|
||||
max_open_positions = 10,
|
||||
updated_at = $1""",
|
||||
BASE_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── Trading Decisions ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_trading_decisions(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(
|
||||
"""INSERT INTO trading_decisions
|
||||
(id, recommendation_id, decision, ticker, computed_position_size,
|
||||
computed_share_quantity, risk_tier_at_decision, portfolio_heat_at_decision,
|
||||
active_pool_at_decision, reserve_pool_at_decision, circuit_breaker_status,
|
||||
correlation_check_result, sector_exposure_check_result,
|
||||
earnings_proximity_flag, is_micro_trade, decision_trace, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12::jsonb, $13::jsonb, $14, $15, $16::jsonb, $17)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
TRADING_DECISION_01, REC_01, "execute", "AAPL", 1855.00, 10,
|
||||
"moderate", 0.15, 10000.00, 2500.00, "inactive",
|
||||
json.dumps({"correlated_tickers": [], "max_correlation": 0.0}),
|
||||
json.dumps({"Technology": 0.15}),
|
||||
False, False,
|
||||
json.dumps({"recommendation_id": str(REC_01), "action": "buy", "confidence": 0.80}),
|
||||
BASE_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── Portfolio Snapshots ───────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_portfolio_snapshots(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(
|
||||
"""INSERT INTO portfolio_snapshots
|
||||
(id, snapshot_date, portfolio_value, active_pool, reserve_pool,
|
||||
daily_return, cumulative_return, unrealized_pnl, realized_pnl,
|
||||
win_count, loss_count, win_rate, sharpe_ratio, max_drawdown,
|
||||
current_drawdown_pct, portfolio_heat, risk_tier, positions, metrics, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
|
||||
$18::jsonb, $19::jsonb, $20)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
PORTFOLIO_SNAP_01, BASE_DATE, 12500.00, 10000.00, 2500.00,
|
||||
0.012, 0.025, 184.25, 50.00,
|
||||
3, 1, 0.75, 1.45, 0.03, 0.01, 0.15, "moderate",
|
||||
json.dumps([
|
||||
{"ticker": "AAPL", "quantity": 10, "unrealized_pnl": 68.00},
|
||||
{"ticker": "MSFT", "quantity": 15, "unrealized_pnl": 116.25},
|
||||
]),
|
||||
json.dumps({"total_trades": 4, "avg_hold_days": 5}),
|
||||
BASE_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── AI Agents (3 — match migration 026 slugs, use ON CONFLICT) ─
|
||||
|
||||
|
||||
async def _seed_ai_agents(conn: asyncpg.Connection) -> None:
|
||||
# Migration 026 seeds these by slug. We insert with our deterministic IDs
|
||||
# using ON CONFLICT on slug to capture the ID if already present.
|
||||
# First, try to insert; if slug exists, just update the id isn't possible
|
||||
# so we delete-and-reinsert with our IDs for test determinism.
|
||||
# Safer approach: delete existing system agents and re-insert with our IDs.
|
||||
await conn.execute(
|
||||
"DELETE FROM agent_performance_log WHERE agent_id IN (SELECT id FROM ai_agents WHERE slug IN ('document-extractor', 'event-classifier', 'thesis-rewriter'))"
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM agent_variants WHERE agent_id IN (SELECT id FROM ai_agents WHERE slug IN ('document-extractor', 'event-classifier', 'thesis-rewriter'))"
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM ai_agents WHERE slug IN ('document-extractor', 'event-classifier', 'thesis-rewriter')"
|
||||
)
|
||||
|
||||
agents = [
|
||||
(AGENT_EXTRACTOR, "Document Intelligence Extractor", "document-extractor",
|
||||
"Extracts structured intelligence from documents.",
|
||||
"ollama", "qwen3.5:9b-fast", "You are a financial document analyst.", "document-intel-v2", "2.0.0",
|
||||
0.0, 32768, 120, 2, True, "system", BASE_TS, BASE_TS),
|
||||
(AGENT_CLASSIFIER, "Global Event Classifier", "event-classifier",
|
||||
"Classifies global news into structured macro events.",
|
||||
"ollama", "qwen3.5:9b-fast", "You classify MACRO-LEVEL global news.", "event-classification-v1", "1.0.0",
|
||||
0.0, 32768, 120, 2, True, "system", BASE_TS, BASE_TS),
|
||||
(AGENT_THESIS, "Thesis Rewriter", "thesis-rewriter",
|
||||
"Rewrites trade thesis summaries into professional prose.",
|
||||
"ollama", "qwen3.5:9b-fast", "You are a concise financial analyst.", "thesis-rewrite-v1", "1.0.0",
|
||||
0.0, 32768, 120, 2, True, "system", BASE_TS, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ai_agents
|
||||
(id, name, slug, purpose, model_provider, model_name, system_prompt,
|
||||
prompt_version, schema_version, temperature, max_tokens, timeout_seconds,
|
||||
max_retries, active, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)""",
|
||||
agents,
|
||||
)
|
||||
|
||||
|
||||
# ── Agent Variants (1 per agent) ─────────────────────────────
|
||||
|
||||
|
||||
async def _seed_agent_variants(conn: asyncpg.Connection) -> None:
|
||||
variants = [
|
||||
(VARIANT_EXTRACTOR, AGENT_EXTRACTOR, "Extractor GPT-4o Variant", "extractor-gpt4o",
|
||||
"Testing GPT-4o for extraction quality comparison.",
|
||||
"openai", "gpt-4o", "You are a financial document analyst.", "",
|
||||
"document-intel-v2-gpt4o", 0.1, 16384, 0, 0, 0, 60, 3, False, BASE_TS, BASE_TS),
|
||||
(VARIANT_CLASSIFIER, AGENT_CLASSIFIER, "Classifier Claude Variant", "classifier-claude",
|
||||
"Testing Claude for event classification.",
|
||||
"anthropic", "claude-sonnet-4-20250514", "You classify MACRO-LEVEL global news.", "",
|
||||
"event-classification-v1-claude", 0.0, 16384, 0, 0, 0, 90, 2, False, BASE_TS, BASE_TS),
|
||||
(VARIANT_THESIS, AGENT_THESIS, "Thesis GPT-4o-mini Variant", "thesis-gpt4o-mini",
|
||||
"Testing GPT-4o-mini for thesis rewriting cost efficiency.",
|
||||
"openai", "gpt-4o-mini", "You are a concise financial analyst.", "",
|
||||
"thesis-rewrite-v1-mini", 0.2, 8192, 0, 0, 0, 30, 2, False, BASE_TS, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO agent_variants
|
||||
(id, agent_id, variant_name, variant_slug, description,
|
||||
model_provider, model_name, system_prompt, user_prompt_template,
|
||||
prompt_version, temperature, max_tokens, context_window,
|
||||
input_token_limit, token_budget, timeout_seconds, max_retries,
|
||||
is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
variants,
|
||||
)
|
||||
|
||||
|
||||
# ── Agent Performance Log ─────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_agent_performance_log(conn: asyncpg.Connection) -> None:
|
||||
logs = [
|
||||
(PERF_LOG_01, AGENT_EXTRACTOR, DOC_01, "AAPL", True, 2500, 0.85, 0, 1200, 800, None, VARIANT_EXTRACTOR, BASE_TS),
|
||||
(PERF_LOG_02, AGENT_CLASSIFIER, DOC_08, None, True, 1800, 0.88, 0, 900, 600, None, VARIANT_CLASSIFIER, BASE_TS),
|
||||
(PERF_LOG_03, AGENT_THESIS, None, "AAPL", True, 1200, 0.90, 0, 500, 300, None, VARIANT_THESIS, BASE_TS),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO agent_performance_log
|
||||
(id, agent_id, document_id, ticker, success, duration_ms, confidence,
|
||||
retry_count, input_tokens, output_tokens, error_message, variant_id, recorded_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
logs,
|
||||
)
|
||||
|
||||
|
||||
# ── Risk Configs ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_risk_configs(conn: asyncpg.Connection) -> None:
|
||||
config = json.dumps({
|
||||
"max_portfolio_heat": 0.25,
|
||||
"max_single_position_pct": 0.05,
|
||||
"max_sector_concentration": 0.30,
|
||||
"daily_loss_limit_pct": 0.03,
|
||||
"macro_enabled": True,
|
||||
"competitive_enabled": True,
|
||||
})
|
||||
await conn.execute(
|
||||
"""INSERT INTO risk_configs (id, name, trading_mode, config, active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
RISK_CONFIG_01, "inttest-default", "paper", config, True, BASE_TS, BASE_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── Audit Events ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed_audit_events(conn: asyncpg.Connection) -> None:
|
||||
events = [
|
||||
(AUDIT_01, "order.submitted", "order", ORDER_01, "system",
|
||||
json.dumps({"ticker": "AAPL", "side": "buy", "qty": 10}), BASE_TS),
|
||||
(AUDIT_02, "order.filled", "order", ORDER_01, "system",
|
||||
json.dumps({"ticker": "AAPL", "fill_price": 185.50, "fill_qty": 10}),
|
||||
BASE_TS + timedelta(seconds=30)),
|
||||
(AUDIT_03, "order.cancelled", "order", ORDER_03, "system",
|
||||
json.dumps({"ticker": "XOM", "reason": "user_request"}),
|
||||
BASE_TS + timedelta(minutes=5)),
|
||||
]
|
||||
await conn.executemany(
|
||||
"""INSERT INTO audit_events (id, event_type, entity_type, entity_id, actor, data, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
events,
|
||||
)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Frontend data dependency tests — verify every page's API calls return valid data.
|
||||
|
||||
Each test function represents one frontend page and calls all the API
|
||||
endpoints that page depends on. For each endpoint we assert:
|
||||
• HTTP 200
|
||||
• Response is non-empty (list has items, or dict has expected keys)
|
||||
|
||||
Uses ``query_client``, ``registry_client``, ``trading_client``, and
|
||||
``seed_ids`` fixtures from conftest.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1 Home
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHomePage:
|
||||
"""Home page depends on: companies, pipeline health, ingestion summary, recommendations."""
|
||||
|
||||
async def test_home_page_deps(self, query_client, seed_ids):
|
||||
# /api/companies
|
||||
resp = await query_client.get("/api/companies")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
# /api/ops/pipeline/health
|
||||
resp = await query_client.get("/api/ops/pipeline/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "document_stages" in data
|
||||
|
||||
# /api/ops/ingestion/summary
|
||||
resp = await query_client.get("/api/ops/ingestion/summary")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "total_runs" in data
|
||||
|
||||
# /api/recommendations
|
||||
resp = await query_client.get("/api/recommendations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2 Companies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompaniesPage:
|
||||
"""Companies list page depends on: companies (query API)."""
|
||||
|
||||
async def test_companies_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/companies")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3 CompanyDetail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompanyDetailPage:
|
||||
"""CompanyDetail depends on: company, sources, trends, recommendations,
|
||||
competitors (registry), exposure (registry), macro-impacts."""
|
||||
|
||||
async def test_company_detail_page_deps(
|
||||
self, query_client, registry_client, seed_ids,
|
||||
):
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
|
||||
# /api/companies/{id}
|
||||
resp = await query_client.get(f"/api/companies/{company_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ticker"] == "AAPL"
|
||||
|
||||
# /api/companies/{id}/sources
|
||||
resp = await query_client.get(f"/api/companies/{company_id}/sources")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
# /api/trends?ticker=AAPL
|
||||
resp = await query_client.get("/api/trends", params={"ticker": "AAPL"})
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
# /api/recommendations?ticker=AAPL
|
||||
resp = await query_client.get("/api/recommendations", params={"ticker": "AAPL"})
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
# /companies/{id}/competitors (registry — no /api/ prefix)
|
||||
resp = await registry_client.get(f"/companies/{company_id}/competitors")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
# /companies/{id}/exposure (registry — no /api/ prefix)
|
||||
resp = await registry_client.get(f"/companies/{company_id}/exposure")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "company_id" in data
|
||||
|
||||
# /api/companies/AAPL/macro-impacts (query API via /api/macro/impacts/AAPL)
|
||||
resp = await query_client.get("/api/macro/impacts/AAPL")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 Documents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentsPage:
|
||||
"""Documents list page depends on: documents."""
|
||||
|
||||
async def test_documents_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/documents")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5 DocumentDetail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentDetailPage:
|
||||
"""DocumentDetail depends on: documents/{id}."""
|
||||
|
||||
async def test_document_detail_page_deps(self, query_client, seed_ids):
|
||||
doc_id = seed_ids["documents"]["DOC_01"]
|
||||
resp = await query_client.get(f"/api/documents/{doc_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == doc_id
|
||||
assert "title" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6 Trends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrendsPage:
|
||||
"""Trends list page depends on: trends."""
|
||||
|
||||
async def test_trends_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/trends")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7 TrendDetail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrendDetailPage:
|
||||
"""TrendDetail depends on: trends/{id}, trends/{id}/projection."""
|
||||
|
||||
async def test_trend_detail_page_deps(self, query_client, seed_ids):
|
||||
trend_id = seed_ids["trends"]["TREND_01"]
|
||||
|
||||
# /api/trends/{id}
|
||||
resp = await query_client.get(f"/api/trends/{trend_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == trend_id
|
||||
assert "trend_direction" in data
|
||||
|
||||
# /api/trends/{id}/projection
|
||||
resp = await query_client.get(f"/api/trends/{trend_id}/projection")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "projected_direction" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8 Recommendations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecommendationsPage:
|
||||
"""Recommendations list page depends on: recommendations."""
|
||||
|
||||
async def test_recommendations_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/recommendations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9 RecommendationDetail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecommendationDetailPage:
|
||||
"""RecommendationDetail depends on: recommendations/{id}."""
|
||||
|
||||
async def test_recommendation_detail_page_deps(self, query_client, seed_ids):
|
||||
rec_id = seed_ids["recommendations"]["REC_01"]
|
||||
resp = await query_client.get(f"/api/recommendations/{rec_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == rec_id
|
||||
assert "ticker" in data
|
||||
assert "evidence" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10 Orders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrdersPage:
|
||||
"""Orders list page depends on: orders."""
|
||||
|
||||
async def test_orders_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/orders")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11 OrderDetail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrderDetailPage:
|
||||
"""OrderDetail depends on: orders/{id}."""
|
||||
|
||||
async def test_order_detail_page_deps(self, query_client, seed_ids):
|
||||
order_id = seed_ids["orders"]["ORDER_01"]
|
||||
resp = await query_client.get(f"/api/orders/{order_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == order_id
|
||||
assert "events" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12 Positions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPositionsPage:
|
||||
"""Positions page depends on: positions."""
|
||||
|
||||
async def test_positions_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/positions")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13 GlobalEvents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGlobalEventsPage:
|
||||
"""GlobalEvents page depends on: macro/events."""
|
||||
|
||||
async def test_global_events_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/macro/events")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14 GlobalEventDetail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGlobalEventDetailPage:
|
||||
"""GlobalEventDetail depends on: macro/events/{id}."""
|
||||
|
||||
async def test_global_event_detail_page_deps(self, query_client, seed_ids):
|
||||
event_id = seed_ids["global_events"]["EVT_01"]
|
||||
resp = await query_client.get(f"/api/macro/events/{event_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == event_id
|
||||
assert "summary" in data
|
||||
assert "impacts" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 15 OpsPipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOpsPipelinePage:
|
||||
"""OpsPipeline page depends on: ops/pipeline/health."""
|
||||
|
||||
async def test_ops_pipeline_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/ops/pipeline/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "document_stages" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 16 OpsIngestion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOpsIngestionPage:
|
||||
"""OpsIngestion page depends on: ingestion summary + throughput."""
|
||||
|
||||
async def test_ops_ingestion_page_deps(self, query_client, seed_ids):
|
||||
# /api/ops/ingestion/summary
|
||||
resp = await query_client.get("/api/ops/ingestion/summary")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "total_runs" in data
|
||||
|
||||
# /api/ops/ingestion/throughput
|
||||
resp = await query_client.get("/api/ops/ingestion/throughput")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 17 OpsCoverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOpsCoveragePage:
|
||||
"""OpsCoverage page depends on: ops/sources/coverage-gaps."""
|
||||
|
||||
async def test_ops_coverage_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/ops/sources/coverage-gaps")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "missing_source_types" in data
|
||||
assert "stale_sources" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 18 Agents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentsPage:
|
||||
"""Agents page depends on: agents."""
|
||||
|
||||
async def test_agents_page_deps(self, query_client, seed_ids):
|
||||
resp = await query_client.get("/api/agents")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list) and len(data) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 19 TradingEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingEnginePage:
|
||||
"""TradingEngine page depends on: trading status, metrics, decisions."""
|
||||
|
||||
async def test_trading_engine_page_deps(self, trading_client, seed_ids):
|
||||
# /api/trading/status
|
||||
resp = await trading_client.get("/api/trading/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "enabled" in data
|
||||
|
||||
# /api/trading/metrics
|
||||
resp = await trading_client.get("/api/trading/metrics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "total_portfolio_value" in data
|
||||
|
||||
# /api/trading/decisions
|
||||
resp = await trading_client.get("/api/trading/decisions")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 20 Trading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingPage:
|
||||
"""Trading page depends on: trading status."""
|
||||
|
||||
async def test_trading_page_deps(self, trading_client, seed_ids):
|
||||
resp = await trading_client.get("/api/trading/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict) and "enabled" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 21 Watchlists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWatchlistsPage:
|
||||
"""Watchlists page depends on: /watchlists (registry client)."""
|
||||
|
||||
async def test_watchlists_page_deps(self, registry_client, seed_ids):
|
||||
resp = await registry_client.get("/watchlists")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Watchlists may be empty — just verify 200 + list type
|
||||
assert isinstance(data, list)
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Tests for the EndpointProfiler timing wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.integration.profiler import SLOW_THRESHOLD_MS, EndpointProfiler
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Percentile calculation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPercentile:
|
||||
"""Unit tests for EndpointProfiler.percentile."""
|
||||
|
||||
def test_single_value(self) -> None:
|
||||
assert EndpointProfiler.percentile([42.0], 50) == 42.0
|
||||
assert EndpointProfiler.percentile([42.0], 99) == 42.0
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
assert EndpointProfiler.percentile([], 50) == 0.0
|
||||
|
||||
def test_two_values(self) -> None:
|
||||
p50 = EndpointProfiler.percentile([10.0, 20.0], 50)
|
||||
assert 10.0 <= p50 <= 20.0
|
||||
|
||||
def test_known_distribution(self) -> None:
|
||||
"""100 evenly spaced values — P50 ≈ 50, P95 ≈ 95, P99 ≈ 99."""
|
||||
values = [float(i) for i in range(1, 101)]
|
||||
p50 = EndpointProfiler.percentile(values, 50)
|
||||
p95 = EndpointProfiler.percentile(values, 95)
|
||||
p99 = EndpointProfiler.percentile(values, 99)
|
||||
assert 45 <= p50 <= 55
|
||||
assert 90 <= p95 <= 100
|
||||
assert 95 <= p99 <= 100
|
||||
|
||||
def test_unsorted_input(self) -> None:
|
||||
"""Percentile should sort internally."""
|
||||
values = [100.0, 1.0, 50.0, 25.0, 75.0]
|
||||
p50 = EndpointProfiler.percentile(values, 50)
|
||||
assert 25.0 <= p50 <= 75.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record / track
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecord:
|
||||
"""Tests for manual recording."""
|
||||
|
||||
def test_record_adds_timing(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /api/foo", 12.5)
|
||||
p.record("GET /api/foo", 15.0)
|
||||
assert len(p._timings["GET /api/foo"]) == 2
|
||||
|
||||
def test_record_multiple_endpoints(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /a", 10.0)
|
||||
p.record("GET /b", 20.0)
|
||||
assert "GET /a" in p._timings
|
||||
assert "GET /b" in p._timings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTrack:
|
||||
"""Tests for the async context manager."""
|
||||
|
||||
async def test_track_records_positive_time(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
async with p.track("GET /api/test"):
|
||||
pass # near-zero but positive
|
||||
assert len(p._timings["GET /api/test"]) == 1
|
||||
assert p._timings["GET /api/test"][0] >= 0
|
||||
|
||||
async def test_track_records_on_exception(self) -> None:
|
||||
"""Timing is recorded even if the wrapped code raises."""
|
||||
p = EndpointProfiler()
|
||||
with pytest.raises(ValueError):
|
||||
async with p.track("GET /api/fail"):
|
||||
raise ValueError("boom")
|
||||
assert len(p._timings["GET /api/fail"]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSummary:
|
||||
"""Tests for the summary dict."""
|
||||
|
||||
def test_empty_profiler(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
s = p.summary()
|
||||
assert s["endpoints"] == {}
|
||||
assert s["slow_endpoints"] == []
|
||||
assert s["total_requests"] == 0
|
||||
assert s["total_duration_ms"] == 0.0
|
||||
|
||||
def test_summary_structure(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
for ms in [10, 20, 30, 40, 50]:
|
||||
p.record("GET /api/companies", float(ms))
|
||||
s = p.summary()
|
||||
|
||||
ep = s["endpoints"]["GET /api/companies"]
|
||||
assert ep["count"] == 5
|
||||
assert "p50_ms" in ep
|
||||
assert "p95_ms" in ep
|
||||
assert "p99_ms" in ep
|
||||
assert "mean_ms" in ep
|
||||
assert s["total_requests"] == 5
|
||||
|
||||
def test_slow_endpoint_flagged(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /slow", SLOW_THRESHOLD_MS + 100)
|
||||
s = p.summary()
|
||||
assert "GET /slow" in s["slow_endpoints"]
|
||||
|
||||
def test_fast_endpoint_not_flagged(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /fast", 10.0)
|
||||
s = p.summary()
|
||||
assert s["slow_endpoints"] == []
|
||||
|
||||
def test_total_duration(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /a", 100.0)
|
||||
p.record("GET /b", 200.0)
|
||||
s = p.summary()
|
||||
assert s["total_duration_ms"] == 300.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# print_summary (smoke test — just ensure no crash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrintSummary:
|
||||
def test_print_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.print_summary()
|
||||
out = capsys.readouterr().out
|
||||
assert "No profiling data" in out
|
||||
|
||||
def test_print_with_data(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /api/companies", 12.0)
|
||||
p.record("GET /api/companies", 25.0)
|
||||
p.record("GET /slow", 600.0)
|
||||
p.print_summary()
|
||||
out = capsys.readouterr().out
|
||||
assert "GET /api/companies" in out
|
||||
assert "SLOW" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWriteJson:
|
||||
def test_write_creates_file(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /api/test", 42.0)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "report.json"
|
||||
p.write_json(path)
|
||||
assert path.exists()
|
||||
data = json.loads(path.read_text())
|
||||
assert "endpoints" in data
|
||||
assert "GET /api/test" in data["endpoints"]
|
||||
|
||||
def test_write_creates_parent_dirs(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /x", 1.0)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "sub" / "dir" / "report.json"
|
||||
p.write_json(path)
|
||||
assert path.exists()
|
||||
|
||||
def test_json_matches_summary(self) -> None:
|
||||
p = EndpointProfiler()
|
||||
p.record("GET /a", 10.0)
|
||||
p.record("GET /a", 20.0)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "out.json"
|
||||
p.write_json(path)
|
||||
from_file = json.loads(path.read_text())
|
||||
assert from_file == p.summary()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProfiledAsyncClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestProfiledAsyncClient:
|
||||
"""Tests for the ProfiledAsyncClient wrapper in conftest."""
|
||||
|
||||
async def test_get_records_timing(self) -> None:
|
||||
"""GET requests are timed and recorded in the profiler."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
await wrapped.get("/api/companies")
|
||||
|
||||
mock_client.get.assert_awaited_once_with("/api/companies")
|
||||
assert "GET /api/companies" in profiler._timings
|
||||
assert len(profiler._timings["GET /api/companies"]) == 1
|
||||
assert profiler._timings["GET /api/companies"][0] >= 0
|
||||
|
||||
async def test_post_records_timing(self) -> None:
|
||||
"""POST requests are timed and recorded in the profiler."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
await wrapped.post("/api/orders", json={"ticker": "AAPL"})
|
||||
|
||||
mock_client.post.assert_awaited_once_with(
|
||||
"/api/orders", json={"ticker": "AAPL"},
|
||||
)
|
||||
assert "POST /api/orders" in profiler._timings
|
||||
assert len(profiler._timings["POST /api/orders"]) == 1
|
||||
|
||||
async def test_put_records_timing(self) -> None:
|
||||
"""PUT requests are timed and recorded."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.put = AsyncMock(return_value=AsyncMock())
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
await wrapped.put("/api/config", json={"key": "val"})
|
||||
|
||||
assert "PUT /api/config" in profiler._timings
|
||||
|
||||
async def test_delete_records_timing(self) -> None:
|
||||
"""DELETE requests are timed and recorded."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.delete = AsyncMock(return_value=AsyncMock())
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
await wrapped.delete("/api/items/123")
|
||||
|
||||
assert "DELETE /api/items/123" in profiler._timings
|
||||
|
||||
async def test_patch_records_timing(self) -> None:
|
||||
"""PATCH requests are timed and recorded."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.patch = AsyncMock(return_value=AsyncMock())
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
await wrapped.patch("/api/items/123", json={"name": "new"})
|
||||
|
||||
assert "PATCH /api/items/123" in profiler._timings
|
||||
|
||||
async def test_multiple_requests_accumulate(self) -> None:
|
||||
"""Multiple requests to the same endpoint accumulate timings."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get = AsyncMock(return_value=AsyncMock())
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
await wrapped.get("/api/companies")
|
||||
await wrapped.get("/api/companies")
|
||||
await wrapped.get("/api/companies")
|
||||
|
||||
assert len(profiler._timings["GET /api/companies"]) == 3
|
||||
|
||||
async def test_attribute_forwarding(self) -> None:
|
||||
"""Non-HTTP attributes are forwarded to the underlying client."""
|
||||
from unittest.mock import AsyncMock, PropertyMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
type(mock_client).base_url = PropertyMock(
|
||||
return_value="http://localhost:8000",
|
||||
)
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
assert wrapped.base_url == "http://localhost:8000"
|
||||
|
||||
async def test_summary_reflects_profiled_requests(self) -> None:
|
||||
"""The profiler summary includes data from profiled client requests."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from tests.integration.conftest import ProfiledAsyncClient
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get = AsyncMock(return_value=AsyncMock())
|
||||
|
||||
profiler = EndpointProfiler()
|
||||
wrapped = ProfiledAsyncClient(mock_client, profiler)
|
||||
|
||||
await wrapped.get("/api/companies")
|
||||
await wrapped.get("/api/trends")
|
||||
|
||||
summary = profiler.summary()
|
||||
assert "GET /api/companies" in summary["endpoints"]
|
||||
assert "GET /api/trends" in summary["endpoints"]
|
||||
assert summary["total_requests"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profiling plugin (conftest_profiling.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProfilingPlugin:
|
||||
"""Tests for the pytest profiling plugin hooks."""
|
||||
|
||||
def test_plugin_registers_profiling_output_option(self, pytestconfig: pytest.Config) -> None:
|
||||
"""The --profiling-output option is registered by the plugin."""
|
||||
# The option should be available since conftest_profiling is loaded
|
||||
val = pytestconfig.getoption("profiling_output", None)
|
||||
# Default value or whatever was passed on the CLI
|
||||
assert val is not None
|
||||
|
||||
def test_profiler_fixture_returns_endpoint_profiler(self, profiler: EndpointProfiler) -> None:
|
||||
"""The session-scoped profiler fixture returns an EndpointProfiler."""
|
||||
assert isinstance(profiler, EndpointProfiler)
|
||||
|
||||
def test_profiler_fixture_is_shared(self, profiler: EndpointProfiler) -> None:
|
||||
"""Recording data via the fixture is visible in the summary."""
|
||||
profiler.record("TEST /plugin-check", 1.0)
|
||||
assert "TEST /plugin-check" in profiler._timings
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Integration tests for the Query API — all 17 frontend-facing endpoints.
|
||||
|
||||
Validates every GET endpoint the frontend calls against the live sandbox
|
||||
with deterministic seed data. Uses the ``query_client`` and ``seed_ids``
|
||||
fixtures from conftest.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1–3 Companies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPICompanies:
|
||||
"""Endpoints: /api/companies, /api/companies/{id}, /api/companies/{id}/sources."""
|
||||
|
||||
async def test_list_companies(self, query_client, seed_ids):
|
||||
"""GET /api/companies — expect at least 5 seeded companies."""
|
||||
resp = await query_client.get("/api/companies")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 5
|
||||
tickers = {c["ticker"] for c in data}
|
||||
assert {"AAPL", "MSFT", "JPM", "JNJ", "XOM"} <= tickers
|
||||
# Every company row must have core fields
|
||||
for c in data:
|
||||
assert "id" in c
|
||||
assert "legal_name" in c
|
||||
assert "sector" in c
|
||||
|
||||
async def test_get_company(self, query_client, seed_ids):
|
||||
"""GET /api/companies/{id} — detail for AAPL."""
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
resp = await query_client.get(f"/api/companies/{company_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ticker"] == "AAPL"
|
||||
assert data["legal_name"] == "Apple Inc"
|
||||
assert "aliases" in data
|
||||
assert "active_source_count" in data
|
||||
|
||||
async def test_list_company_sources(self, query_client, seed_ids):
|
||||
"""GET /api/companies/{id}/sources — AAPL has at least 1 source."""
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
resp = await query_client.get(f"/api/companies/{company_id}/sources")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
assert "source_type" in data[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4–5 Documents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIDocuments:
|
||||
"""Endpoints: /api/documents, /api/documents/{id}."""
|
||||
|
||||
async def test_list_documents(self, query_client, seed_ids):
|
||||
"""GET /api/documents — expect at least 10 seeded documents."""
|
||||
resp = await query_client.get("/api/documents")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 10
|
||||
for doc in data:
|
||||
assert "id" in doc
|
||||
assert "document_type" in doc
|
||||
assert "title" in doc
|
||||
|
||||
async def test_get_document(self, query_client, seed_ids):
|
||||
"""GET /api/documents/{id} — detail with intelligence."""
|
||||
doc_id = seed_ids["documents"]["DOC_01"]
|
||||
resp = await query_client.get(f"/api/documents/{doc_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == doc_id
|
||||
assert "title" in data
|
||||
assert "document_type" in data
|
||||
# Intelligence extraction should be present for seeded docs
|
||||
assert "intelligence" in data
|
||||
assert "company_mentions" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6–7 Trends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPITrends:
|
||||
"""Endpoints: /api/trends, /api/trends/{id}."""
|
||||
|
||||
async def test_list_trends(self, query_client, seed_ids):
|
||||
"""GET /api/trends — expect at least 5 seeded trend windows."""
|
||||
resp = await query_client.get("/api/trends")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 5
|
||||
for t in data:
|
||||
assert "id" in t
|
||||
assert "trend_direction" in t
|
||||
assert "confidence" in t
|
||||
|
||||
async def test_get_trend(self, query_client, seed_ids):
|
||||
"""GET /api/trends/{id} — detail for first seeded trend."""
|
||||
trend_id = seed_ids["trends"]["TREND_01"]
|
||||
resp = await query_client.get(f"/api/trends/{trend_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == trend_id
|
||||
assert data["trend_direction"] in ("bullish", "bearish", "mixed")
|
||||
assert 0 <= data["confidence"] <= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8–9 Recommendations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIRecommendations:
|
||||
"""Endpoints: /api/recommendations, /api/recommendations/{id}."""
|
||||
|
||||
async def test_list_recommendations(self, query_client, seed_ids):
|
||||
"""GET /api/recommendations — expect at least 5 seeded recs."""
|
||||
resp = await query_client.get("/api/recommendations", params={"latest": "false"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 5
|
||||
for r in data:
|
||||
assert "id" in r
|
||||
assert "ticker" in r
|
||||
assert "action" in r
|
||||
assert "confidence" in r
|
||||
|
||||
async def test_get_recommendation(self, query_client, seed_ids):
|
||||
"""GET /api/recommendations/{id} — detail with evidence."""
|
||||
rec_id = seed_ids["recommendations"]["REC_01"]
|
||||
resp = await query_client.get(f"/api/recommendations/{rec_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == rec_id
|
||||
assert "ticker" in data
|
||||
assert "thesis" in data
|
||||
assert "evidence" in data
|
||||
assert "risk_evaluation" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10–11 Orders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIOrders:
|
||||
"""Endpoints: /api/orders, /api/orders/{id}."""
|
||||
|
||||
async def test_list_orders(self, query_client, seed_ids):
|
||||
"""GET /api/orders — expect at least 3 seeded orders."""
|
||||
resp = await query_client.get("/api/orders")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 3
|
||||
statuses = {o["status"] for o in data}
|
||||
# Seed has filled, pending, cancelled
|
||||
assert len(statuses) >= 2
|
||||
for o in data:
|
||||
assert "id" in o
|
||||
assert "ticker" in o
|
||||
assert "side" in o
|
||||
|
||||
async def test_get_order(self, query_client, seed_ids):
|
||||
"""GET /api/orders/{id} — detail with events and audit trail."""
|
||||
order_id = seed_ids["orders"]["ORDER_01"]
|
||||
resp = await query_client.get(f"/api/orders/{order_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == order_id
|
||||
assert "ticker" in data
|
||||
assert "events" in data
|
||||
assert isinstance(data["events"], list)
|
||||
assert "audit_trail" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12 Positions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIPositions:
|
||||
"""Endpoint: /api/positions."""
|
||||
|
||||
async def test_list_positions(self, query_client, seed_ids):
|
||||
"""GET /api/positions — expect at least 2 seeded positions."""
|
||||
resp = await query_client.get("/api/positions")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 2
|
||||
for p in data:
|
||||
assert "id" in p
|
||||
assert "ticker" in p
|
||||
assert "quantity" in p
|
||||
assert "unrealized_pnl" in p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13 Pipeline Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIOps:
|
||||
"""Endpoints: /api/ops/pipeline/health, /api/ops/ingestion/summary, /api/ops/sources/coverage-gaps."""
|
||||
|
||||
async def test_pipeline_health(self, query_client, seed_ids):
|
||||
"""GET /api/ops/pipeline/health — returns structured health data."""
|
||||
resp = await query_client.get("/api/ops/pipeline/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "hours" in data
|
||||
assert "document_stages" in data
|
||||
assert "parsing" in data
|
||||
assert "extraction" in data
|
||||
assert "aggregation" in data
|
||||
assert "queue_depths" in data
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 14 Ingestion Summary
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def test_ingestion_summary(self, query_client, seed_ids):
|
||||
"""GET /api/ops/ingestion/summary — returns ingestion stats."""
|
||||
resp = await query_client.get("/api/ops/ingestion/summary")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "hours" in data
|
||||
assert "total_runs" in data
|
||||
assert "by_source_type" in data
|
||||
assert isinstance(data["by_source_type"], list)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 15 Coverage Gaps
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def test_coverage_gaps(self, query_client, seed_ids):
|
||||
"""GET /api/ops/sources/coverage-gaps — returns gap analysis."""
|
||||
resp = await query_client.get("/api/ops/sources/coverage-gaps")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "missing_source_types" in data
|
||||
assert "stale_sources" in data
|
||||
assert isinstance(data["missing_source_types"], list)
|
||||
assert isinstance(data["stale_sources"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 16–17 Agents & Variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIAgents:
|
||||
"""Endpoints: /api/agents, /api/agents/{id}/variants."""
|
||||
|
||||
async def test_list_agents(self, query_client, seed_ids):
|
||||
"""GET /api/agents — expect at least 3 seeded agents."""
|
||||
resp = await query_client.get("/api/agents")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 3
|
||||
for a in data:
|
||||
assert "id" in a
|
||||
assert "name" in a
|
||||
assert "slug" in a
|
||||
|
||||
async def test_list_agent_variants(self, query_client, seed_ids):
|
||||
"""GET /api/agents/{id}/variants — variants for the extractor agent."""
|
||||
agent_id = seed_ids["agents"]["extractor"]
|
||||
resp = await query_client.get(f"/api/agents/{agent_id}/variants")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
for v in data:
|
||||
assert "id" in v
|
||||
assert "variant_name" in v
|
||||
assert v["agent_id"] == agent_id
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Integration tests for the Symbol Registry API — all 8 frontend-facing endpoints.
|
||||
|
||||
Validates every endpoint the frontend calls against the live sandbox
|
||||
with deterministic seed data. Uses the ``registry_client`` and ``seed_ids``
|
||||
fixtures from conftest.py.
|
||||
|
||||
Routes are at the root level (no /api/ prefix):
|
||||
/companies, /companies/{id}, /companies/{id}/sources,
|
||||
/companies/{id}/aliases, /companies/{id}/competitors,
|
||||
/companies/{id}/exposure
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1–2 List & Get Companies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryCompanies:
|
||||
"""Endpoints: GET /companies, GET /companies/{id}."""
|
||||
|
||||
async def test_list_companies(self, registry_client, seed_ids):
|
||||
"""GET /companies — expect 5 seeded active companies."""
|
||||
resp = await registry_client.get("/companies")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 5
|
||||
tickers = {c["ticker"] for c in data}
|
||||
assert {"AAPL", "MSFT", "JPM", "JNJ", "XOM"} <= tickers
|
||||
for c in data:
|
||||
assert "id" in c
|
||||
assert "legal_name" in c
|
||||
assert "active" in c
|
||||
|
||||
async def test_get_company(self, registry_client, seed_ids):
|
||||
"""GET /companies/{id} — detail for AAPL."""
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
resp = await registry_client.get(f"/companies/{company_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ticker"] == "AAPL"
|
||||
assert data["legal_name"] == "Apple Inc"
|
||||
assert data["sector"] == "Technology"
|
||||
assert data["active"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3 Create Company
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryCreateCompany:
|
||||
"""Endpoint: POST /companies."""
|
||||
|
||||
async def test_create_company(self, registry_client, seed_ids):
|
||||
"""POST /companies — create a new company with ticker TEST."""
|
||||
payload = {
|
||||
"ticker": "TEST",
|
||||
"legal_name": "Test Corp",
|
||||
"exchange": "NYSE",
|
||||
"sector": "Technology",
|
||||
"industry": "Software",
|
||||
"market_cap_bucket": "small",
|
||||
}
|
||||
resp = await registry_client.post("/companies", json=payload)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["ticker"] == "TEST"
|
||||
assert data["legal_name"] == "Test Corp"
|
||||
assert data["exchange"] == "NYSE"
|
||||
assert data["sector"] == "Technology"
|
||||
assert data["active"] is True
|
||||
assert "id" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 Update Company
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryUpdateCompany:
|
||||
"""Endpoint: PUT /companies/{id}."""
|
||||
|
||||
async def test_update_company_sector(self, registry_client, seed_ids):
|
||||
"""PUT /companies/{id} — update XOM's sector."""
|
||||
company_id = seed_ids["companies"]["XOM"]
|
||||
payload = {
|
||||
"ticker": "XOM",
|
||||
"legal_name": "Exxon Mobil Corp",
|
||||
"exchange": "NYSE",
|
||||
"sector": "Energy & Utilities",
|
||||
"industry": "Oil & Gas Integrated",
|
||||
"market_cap_bucket": "mega",
|
||||
}
|
||||
resp = await registry_client.put(f"/companies/{company_id}", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sector"] == "Energy & Utilities"
|
||||
assert data["ticker"] == "XOM"
|
||||
assert data["id"] == company_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5 Company Sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistrySources:
|
||||
"""Endpoint: GET /companies/{id}/sources."""
|
||||
|
||||
async def test_list_sources(self, registry_client, seed_ids):
|
||||
"""GET /companies/{id}/sources — AAPL has at least 1 source."""
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
resp = await registry_client.get(f"/companies/{company_id}/sources")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
for s in data:
|
||||
assert "id" in s
|
||||
assert "source_type" in s
|
||||
assert "source_name" in s
|
||||
assert "active" in s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6 Company Aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryAliases:
|
||||
"""Endpoint: GET /companies/{id}/aliases."""
|
||||
|
||||
async def test_list_aliases(self, registry_client, seed_ids):
|
||||
"""GET /companies/{id}/aliases — AAPL has at least 1 alias."""
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
resp = await registry_client.get(f"/companies/{company_id}/aliases")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
for a in data:
|
||||
assert "id" in a
|
||||
assert "alias" in a
|
||||
assert "alias_type" in a
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7 Competitors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryCompetitors:
|
||||
"""Endpoint: GET /companies/{id}/competitors."""
|
||||
|
||||
async def test_list_competitors(self, registry_client, seed_ids):
|
||||
"""GET /companies/{id}/competitors — AAPL has MSFT as competitor."""
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
resp = await registry_client.get(f"/companies/{company_id}/competitors")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
# AAPL ↔ MSFT relationship exists in seed data
|
||||
msft_id = seed_ids["companies"]["MSFT"]
|
||||
partner_ids = set()
|
||||
for rel in data:
|
||||
assert "id" in rel
|
||||
assert "relationship_type" in rel
|
||||
assert "strength" in rel
|
||||
# The "other" company should be enriched with ticker
|
||||
if rel.get("company_a_id") == company_id:
|
||||
partner_ids.add(rel["company_b_id"])
|
||||
else:
|
||||
partner_ids.add(rel["company_a_id"])
|
||||
assert msft_id in partner_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8 Exposure Profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryExposure:
|
||||
"""Endpoint: GET /companies/{id}/exposure."""
|
||||
|
||||
async def test_get_exposure(self, registry_client, seed_ids):
|
||||
"""GET /companies/{id}/exposure — AAPL has an active exposure profile."""
|
||||
company_id = seed_ids["companies"]["AAPL"]
|
||||
resp = await registry_client.get(f"/companies/{company_id}/exposure")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["company_id"] == company_id
|
||||
assert data["active"] is True
|
||||
assert data["market_position_tier"] == "global_leader"
|
||||
assert isinstance(data["geographic_revenue_mix"], dict)
|
||||
assert "North America" in data["geographic_revenue_mix"]
|
||||
assert isinstance(data["supply_chain_regions"], list)
|
||||
assert len(data["supply_chain_regions"]) >= 1
|
||||
assert isinstance(data["key_input_commodities"], list)
|
||||
assert isinstance(data["regulatory_jurisdictions"], list)
|
||||
assert 0 <= data["export_dependency_pct"] <= 1
|
||||
assert 0 <= data["confidence"] <= 1
|
||||
assert data["version"] >= 1
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Integration tests for the Risk Engine API — all 4 frontend-facing endpoints.
|
||||
|
||||
Validates every endpoint the frontend calls against the live sandbox
|
||||
with deterministic seed data. Uses the ``risk_client`` and ``seed_ids``
|
||||
fixtures from conftest.py.
|
||||
|
||||
Routes are at the root level (no prefix):
|
||||
/health, /evaluate, /approvals/pending, /approvals/{id}/review
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1 Health Check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskHealth:
|
||||
"""Endpoint: GET /health."""
|
||||
|
||||
async def test_health(self, risk_client):
|
||||
"""GET /health — returns {"status": "ok"}."""
|
||||
resp = await risk_client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2 Evaluate Order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskEvaluate:
|
||||
"""Endpoint: POST /evaluate."""
|
||||
|
||||
async def test_evaluate_order(self, risk_client):
|
||||
"""POST /evaluate — evaluate a proposed order and verify response structure."""
|
||||
payload = {
|
||||
"order": {
|
||||
"ticker": "AAPL",
|
||||
"action": "buy",
|
||||
"quantity": 10,
|
||||
"estimated_value": 1855.00,
|
||||
"confidence": 0.85,
|
||||
"recommendation_id": None,
|
||||
"sector": "Technology",
|
||||
},
|
||||
"config": None,
|
||||
"state": None,
|
||||
}
|
||||
resp = await risk_client.post("/evaluate", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Core RiskEvaluation fields
|
||||
assert "evaluation_id" in data
|
||||
assert "ticker" in data
|
||||
assert data["ticker"] == "AAPL"
|
||||
assert "eligible" in data
|
||||
assert isinstance(data["eligible"], bool)
|
||||
assert "rejection_reasons" in data
|
||||
assert isinstance(data["rejection_reasons"], list)
|
||||
assert "checks" in data
|
||||
assert isinstance(data["checks"], list)
|
||||
assert "evaluated_at" in data
|
||||
|
||||
async def test_evaluate_order_minimal(self, risk_client):
|
||||
"""POST /evaluate — minimal order with only required fields."""
|
||||
payload = {
|
||||
"order": {
|
||||
"ticker": "MSFT",
|
||||
},
|
||||
}
|
||||
resp = await risk_client.post("/evaluate", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "evaluation_id" in data
|
||||
assert "eligible" in data
|
||||
assert "rejection_reasons" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3 Pending Approvals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskApprovalsPending:
|
||||
"""Endpoint: GET /approvals/pending."""
|
||||
|
||||
async def test_list_pending_approvals(self, risk_client):
|
||||
"""GET /approvals/pending — returns 200 with a list (may be empty in sandbox)."""
|
||||
resp = await risk_client.get("/approvals/pending")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 Review Approval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRiskApprovalReview:
|
||||
"""Endpoint: POST /approvals/{id}/review."""
|
||||
|
||||
async def test_review_nonexistent_approval(self, risk_client):
|
||||
"""POST /approvals/{id}/review — 404 for a non-existent approval ID."""
|
||||
fake_id = "00000000-0000-4000-ffff-000000000099"
|
||||
payload = {
|
||||
"approved": True,
|
||||
"reviewed_by": "test-operator",
|
||||
"review_note": "integration test",
|
||||
}
|
||||
resp = await risk_client.post(
|
||||
f"/approvals/{fake_id}/review", json=payload,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Integration tests for the Trading Engine API — all 12 frontend-facing endpoints.
|
||||
|
||||
Validates every endpoint the frontend calls against the live sandbox
|
||||
with deterministic seed data. Uses the ``trading_client`` and ``seed_ids``
|
||||
fixtures from conftest.py.
|
||||
|
||||
Routes:
|
||||
/health, /ready — probes (root level)
|
||||
/api/trading/status — engine status
|
||||
/api/trading/config — config update
|
||||
/api/trading/pause, /api/trading/resume — engine control
|
||||
/api/trading/decisions — decision audit trail
|
||||
/api/trading/metrics — current portfolio metrics
|
||||
/api/trading/metrics/history — historical snapshots
|
||||
/api/trading/notifications/config — notification config
|
||||
/api/trading/notifications/history — notification history
|
||||
/api/trading/override/order — manual override order
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1 Health Check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingHealth:
|
||||
"""Endpoint: GET /health."""
|
||||
|
||||
async def test_health(self, trading_client):
|
||||
"""GET /health — returns {"status": "ok"}."""
|
||||
resp = await trading_client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2 Readiness Check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingReady:
|
||||
"""Endpoint: GET /ready."""
|
||||
|
||||
async def test_ready(self, trading_client):
|
||||
"""GET /ready — returns readiness state."""
|
||||
resp = await trading_client.get("/ready")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "ready" in data
|
||||
assert isinstance(data["ready"], bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3 Engine Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingStatus:
|
||||
"""Endpoint: GET /api/trading/status."""
|
||||
|
||||
async def test_status(self, trading_client):
|
||||
"""GET /api/trading/status — returns engine state with expected fields."""
|
||||
resp = await trading_client.get("/api/trading/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "enabled" in data
|
||||
assert "paused" in data
|
||||
assert "risk_tier" in data
|
||||
assert "active_pool" in data
|
||||
assert "reserve_pool" in data
|
||||
assert "portfolio_heat" in data
|
||||
assert "open_positions" in data
|
||||
assert isinstance(data["enabled"], bool)
|
||||
assert isinstance(data["paused"], bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4 Update Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingConfig:
|
||||
"""Endpoint: PUT /api/trading/config."""
|
||||
|
||||
async def test_update_config(self, trading_client):
|
||||
"""PUT /api/trading/config — update risk_tier and verify response."""
|
||||
payload = {"risk_tier": "conservative"}
|
||||
resp = await trading_client.put("/api/trading/config", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "previous" in data
|
||||
assert "updated" in data
|
||||
assert data["updated"]["risk_tier"] == "conservative"
|
||||
assert "changed_at" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5 Pause Engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingPause:
|
||||
"""Endpoint: POST /api/trading/pause."""
|
||||
|
||||
async def test_pause(self, trading_client):
|
||||
"""POST /api/trading/pause — returns paused=True."""
|
||||
resp = await trading_client.post("/api/trading/pause")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["paused"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6 Resume Engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingResume:
|
||||
"""Endpoint: POST /api/trading/resume."""
|
||||
|
||||
async def test_resume(self, trading_client):
|
||||
"""POST /api/trading/resume — returns paused=False."""
|
||||
resp = await trading_client.post("/api/trading/resume")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["paused"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7 Trading Decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingDecisions:
|
||||
"""Endpoint: GET /api/trading/decisions."""
|
||||
|
||||
async def test_list_decisions(self, trading_client, seed_ids):
|
||||
"""GET /api/trading/decisions — expect at least 1 decision from seed data."""
|
||||
resp = await trading_client.get("/api/trading/decisions")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
for d in data:
|
||||
assert "id" in d
|
||||
assert "decision" in d
|
||||
assert "ticker" in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8 Current Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingMetrics:
|
||||
"""Endpoint: GET /api/trading/metrics."""
|
||||
|
||||
async def test_current_metrics(self, trading_client):
|
||||
"""GET /api/trading/metrics — returns portfolio metrics structure."""
|
||||
resp = await trading_client.get("/api/trading/metrics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "total_portfolio_value" in data
|
||||
assert "active_pool" in data
|
||||
assert "reserve_pool" in data
|
||||
assert "unrealized_pnl" in data
|
||||
assert "realized_pnl" in data
|
||||
assert "daily_pnl" in data
|
||||
assert "win_rate" in data
|
||||
assert "sharpe_ratio" in data
|
||||
assert "max_drawdown" in data
|
||||
assert "portfolio_heat" in data
|
||||
# All values should be numeric
|
||||
for key in data:
|
||||
assert isinstance(data[key], (int, float)), f"{key} should be numeric"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9 Metrics History
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingMetricsHistory:
|
||||
"""Endpoint: GET /api/trading/metrics/history."""
|
||||
|
||||
async def test_metrics_history(self, trading_client):
|
||||
"""GET /api/trading/metrics/history — returns a list of snapshots."""
|
||||
resp = await trading_client.get("/api/trading/metrics/history")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
# Seed data includes at least 1 portfolio snapshot
|
||||
if len(data) > 0:
|
||||
snap = data[0]
|
||||
assert "portfolio_value" in snap
|
||||
assert "snapshot_date" in snap
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10 Notification Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingNotificationConfig:
|
||||
"""Endpoint: GET /api/trading/notifications/config."""
|
||||
|
||||
async def test_get_notification_config(self, trading_client):
|
||||
"""GET /api/trading/notifications/config — returns notification settings."""
|
||||
resp = await trading_client.get("/api/trading/notifications/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "sms_enabled" in data
|
||||
assert "email_enabled" in data
|
||||
assert isinstance(data["sms_enabled"], bool)
|
||||
assert isinstance(data["email_enabled"], bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11 Notification History
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingNotificationHistory:
|
||||
"""Endpoint: GET /api/trading/notifications/history."""
|
||||
|
||||
async def test_notification_history(self, trading_client):
|
||||
"""GET /api/trading/notifications/history — returns a list (may be empty)."""
|
||||
resp = await trading_client.get("/api/trading/notifications/history")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12 Override Order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTradingOverride:
|
||||
"""Endpoint: POST /api/trading/override/order."""
|
||||
|
||||
async def test_submit_override_order(self, trading_client):
|
||||
"""POST /api/trading/override/order — submit a valid market order.
|
||||
|
||||
The override endpoint may fail if the trading engine isn't fully
|
||||
configured (e.g. no Redis). We accept either a successful 202
|
||||
or a structured error (4xx/5xx with JSON body).
|
||||
"""
|
||||
payload = {
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 1.0,
|
||||
"order_type": "market",
|
||||
}
|
||||
resp = await trading_client.post(
|
||||
"/api/trading/override/order", json=payload,
|
||||
)
|
||||
# Accept 202 (queued) or a structured error response
|
||||
assert resp.status_code in (200, 202, 400, 422, 503)
|
||||
data = resp.json()
|
||||
if resp.status_code == 202:
|
||||
assert "job_id" in data
|
||||
assert data["status"] == "queued"
|
||||
assert data["ticker"] == "AAPL"
|
||||
assert data["side"] == "buy"
|
||||
assert data["quantity"] == 1.0
|
||||
else:
|
||||
# Structured error — just verify it's a dict with some info
|
||||
assert isinstance(data, dict)
|
||||
Reference in New Issue
Block a user