From 8469bf7ffacd74613937b2f9abd3297393ebd2f3 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 16 May 2026 03:40:12 +0900 Subject: [PATCH] feat(signal_v2): stock_client + 6 integration tests httpx async client with custom retry loop (max 3, exponential 1s/2s/4s), memory dict cache (portfolio 60s / news-sentiment 300s / screener 60s), X-WebAI-Key auth header injection. Stale fallback returns last successful response with logger.warning on persistent failures. 6 integration tests pass with respx httpx mock. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 +- signal_v2/pytest.ini | 3 + signal_v2/stock_client.py | 128 ++++++++++++++++++++ signal_v2/tests/conftest.py | 18 +++ signal_v2/tests/test_stock_client.py | 168 +++++++++++++++++++++++++++ 5 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 signal_v2/pytest.ini create mode 100644 signal_v2/stock_client.py create mode 100644 signal_v2/tests/conftest.py create mode 100644 signal_v2/tests/test_stock_client.py diff --git a/.gitignore b/.gitignore index c92758d..e1c9e86 100644 --- a/.gitignore +++ b/.gitignore @@ -47,9 +47,11 @@ daily_trade_history.json watchlist.json bot_ipc.json -# Test +# Test (top-level only; signal_v2/tests tracked separately) tests/ tests/* +!signal_v2/tests/ +!signal_v2/tests/** # System Thumbs.db diff --git a/signal_v2/pytest.ini b/signal_v2/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/signal_v2/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/signal_v2/stock_client.py b/signal_v2/stock_client.py new file mode 100644 index 0000000..d718711 --- /dev/null +++ b/signal_v2/stock_client.py @@ -0,0 +1,128 @@ +"""Stock API HTTP client — async httpx + retry + memory cache.""" +from __future__ import annotations +import asyncio +import logging +import time +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +# Cache TTL by endpoint (seconds) +_TTL = { + "portfolio": 60.0, + "news-sentiment": 300.0, + "screener-preview": 60.0, +} + +# Retry policy +_MAX_ATTEMPTS = 3 +_RETRY_STATUSES = {429, 500, 502, 503, 504} + + +class StockClient: + """stock API wrapper. Async httpx + self-retry + memory cache.""" + + def __init__(self, base_url: str, api_key: str, timeout: float = 10.0): + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._client = httpx.AsyncClient(timeout=timeout) + # cache: key → (data, timestamp_monotonic) + self._cache: dict[str, tuple[Any, float]] = {} + + async def close(self) -> None: + await self._client.aclose() + + async def get_portfolio(self) -> dict: + return await self._cached_request( + "portfolio", "GET", "/api/webai/portfolio" + ) + + async def get_news_sentiment(self, date: str | None = None) -> dict: + path = "/api/webai/news-sentiment" + if date is not None: + path += f"?date={date}" + cache_key = f"news-sentiment:{date or 'latest'}" + return await self._cached_request( + cache_key, "GET", path, _ttl_key="news-sentiment" + ) + + async def run_screener_preview( + self, weights: dict | None = None, top_n: int = 20 + ) -> dict: + body = {"mode": "preview", "top_n": top_n} + if weights is not None: + body["weights"] = weights + return await self._cached_request( + "screener-preview", + "POST", + "/api/stock/screener/run", + json=body, + _ttl_key="screener-preview", + ) + + async def _cached_request( + self, + cache_key: str, + method: str, + path: str, + *, + _ttl_key: str | None = None, + **kwargs, + ) -> dict: + ttl_key = _ttl_key or cache_key + ttl = _TTL.get(ttl_key, 60.0) + # Fresh cache hit? + if cache_key in self._cache: + data, ts = self._cache[cache_key] + if time.monotonic() - ts < ttl: + return data + + # Fetch (with retry) + try: + data = await self._request_with_retry(method, path, **kwargs) + self._cache[cache_key] = (data, time.monotonic()) + return data + except Exception: + # Stale fallback: serve old cached value if exists + if cache_key in self._cache: + stale_data, stale_ts = self._cache[cache_key] + age = time.monotonic() - stale_ts + logger.warning( + "serving stale cache for %s (age=%.1fs)", cache_key, age + ) + return stale_data + raise + + async def _request_with_retry(self, method: str, path: str, **kwargs) -> dict: + url = f"{self._base_url}{path}" + headers = self._auth_headers() + last_exc: Exception | None = None + for attempt in range(_MAX_ATTEMPTS): + try: + response = await self._client.request( + method, url, headers=headers, **kwargs + ) + if response.status_code in _RETRY_STATUSES: + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2**attempt) + continue + response.raise_for_status() + response.raise_for_status() + return response.json() + except httpx.TimeoutException as e: + last_exc = e + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2**attempt) + continue + raise + except httpx.HTTPStatusError as e: + last_exc = e + raise + if last_exc is not None: + raise last_exc + raise RuntimeError("retry exhausted") + + def _auth_headers(self) -> dict[str, str]: + return {"X-WebAI-Key": self._api_key} diff --git a/signal_v2/tests/conftest.py b/signal_v2/tests/conftest.py new file mode 100644 index 0000000..73146c7 --- /dev/null +++ b/signal_v2/tests/conftest.py @@ -0,0 +1,18 @@ +"""Pytest fixtures for signal_v2 tests.""" +from pathlib import Path + +import pytest +import respx + + +@pytest.fixture +def tmp_dedup_db(tmp_path) -> Path: + """SQLite 단위 테스트용 임시 DB path.""" + return tmp_path / "test_signal_v2.db" + + +@pytest.fixture +def mock_stock_api(): + """respx 로 stock API mock. base_url 은 테스트마다 임의.""" + with respx.mock(base_url="https://test.stock.local", assert_all_called=False) as mock: + yield mock diff --git a/signal_v2/tests/test_stock_client.py b/signal_v2/tests/test_stock_client.py new file mode 100644 index 0000000..54bb9ab --- /dev/null +++ b/signal_v2/tests/test_stock_client.py @@ -0,0 +1,168 @@ +"""Tests for stock_client.StockClient.""" +import asyncio +import logging +import pytest +import httpx + +from signal_v2.stock_client import StockClient + + +BASE_URL = "https://test.stock.local" +API_KEY = "test-secret" + + +async def test_get_portfolio_normal_returns_dict_with_pnl_pct(mock_stock_api): + """정상 200 응답 + cache 저장.""" + mock_stock_api.get("/api/webai/portfolio").mock( + return_value=httpx.Response( + 200, + json={ + "holdings": [{"ticker": "005930", "pnl_pct": 0.047}], + "cash": [], + "summary": {}, + }, + ) + ) + client = StockClient(BASE_URL, API_KEY) + try: + result = await client.get_portfolio() + assert result["holdings"][0]["pnl_pct"] == 0.047 + # Cache populated + assert len(client._cache) >= 1 + finally: + await client.close() + + +async def test_get_portfolio_uses_cache_within_ttl(mock_stock_api): + """60s TTL 내 두번째 호출 = mock 콜 1회.""" + route = mock_stock_api.get("/api/webai/portfolio").mock( + return_value=httpx.Response( + 200, json={"holdings": [], "cash": [], "summary": {}} + ) + ) + client = StockClient(BASE_URL, API_KEY) + try: + await client.get_portfolio() + await client.get_portfolio() # second call within TTL + assert route.call_count == 1 + finally: + await client.close() + + +async def test_get_portfolio_refetches_after_ttl_expiry(mock_stock_api, monkeypatch): + """TTL 만료 후 재호출 = mock 콜 2회. time.monotonic 모킹.""" + route = mock_stock_api.get("/api/webai/portfolio").mock( + return_value=httpx.Response( + 200, json={"holdings": [], "cash": [], "summary": {}} + ) + ) + # Fake clock: starts at 0, jumps to 61 between calls + fake_time = [0.0] + monkeypatch.setattr( + "signal_v2.stock_client.time.monotonic", lambda: fake_time[0] + ) + + client = StockClient(BASE_URL, API_KEY) + try: + await client.get_portfolio() + fake_time[0] = 61.0 # 60s TTL 만료 + await client.get_portfolio() + assert route.call_count == 2 + finally: + await client.close() + + +async def test_get_portfolio_retries_3_times_on_timeout(mock_stock_api, monkeypatch): + """timeout 2번 + 200 1번 → 최종 성공. exponential sleep 호출 검증.""" + sleep_calls = [] + + async def fake_sleep(s): + sleep_calls.append(s) + + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + mock_stock_api.get("/api/webai/portfolio").mock( + side_effect=[ + httpx.TimeoutException("timeout 1"), + httpx.TimeoutException("timeout 2"), + httpx.Response( + 200, json={"holdings": [], "cash": [], "summary": {}} + ), + ] + ) + client = StockClient(BASE_URL, API_KEY) + try: + result = await client.get_portfolio() + assert result["holdings"] == [] + assert sleep_calls == [1, 2] # exponential 1s, 2s + finally: + await client.close() + + +async def test_get_portfolio_429_triggers_backoff(mock_stock_api, monkeypatch): + """429 → 1s backoff → 200.""" + sleep_calls = [] + + async def fake_sleep(s): + sleep_calls.append(s) + + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + mock_stock_api.get("/api/webai/portfolio").mock( + side_effect=[ + httpx.Response(429, text="rate limit"), + httpx.Response( + 200, json={"holdings": [], "cash": [], "summary": {}} + ), + ] + ) + client = StockClient(BASE_URL, API_KEY) + try: + result = await client.get_portfolio() + assert result["holdings"] == [] + assert sleep_calls == [1] + finally: + await client.close() + + +async def test_get_portfolio_falls_back_to_stale_on_all_failures( + mock_stock_api, monkeypatch, caplog +): + """cache 에 이전 성공 응답 + 모든 retry 5xx → stale 반환 + logger.warning.""" + # No-op sleep for fast test + async def fake_sleep(s): + return None + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + # Patch time.monotonic BEFORE first call so cached timestamp uses fake clock + fake_time = [0.0] + monkeypatch.setattr( + "signal_v2.stock_client.time.monotonic", lambda: fake_time[0] + ) + + # First call succeeds + route1 = mock_stock_api.get("/api/webai/portfolio").mock( + return_value=httpx.Response( + 200, + json={"holdings": [{"ticker": "005930"}], "cash": [], "summary": {}}, + ) + ) + client = StockClient(BASE_URL, API_KEY) + try: + first = await client.get_portfolio() + assert first["holdings"][0]["ticker"] == "005930" + + # Advance fake clock past TTL (60s) so cache is stale + fake_time[0] = 61.0 + + # Now mock to return 500s persistently + route1.mock(return_value=httpx.Response(500, text="server error")) + + with caplog.at_level(logging.WARNING, logger="signal_v2.stock_client"): + result = await client.get_portfolio() + assert result["holdings"][0]["ticker"] == "005930" # stale data returned + assert any( + "stale" in rec.message.lower() for rec in caplog.records + ) + finally: + await client.close()