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) <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -47,9 +47,11 @@ daily_trade_history.json
|
|||||||
watchlist.json
|
watchlist.json
|
||||||
bot_ipc.json
|
bot_ipc.json
|
||||||
|
|
||||||
# Test
|
# Test (top-level only; signal_v2/tests tracked separately)
|
||||||
tests/
|
tests/
|
||||||
tests/*
|
tests/*
|
||||||
|
!signal_v2/tests/
|
||||||
|
!signal_v2/tests/**
|
||||||
|
|
||||||
# System
|
# System
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
3
signal_v2/pytest.ini
Normal file
3
signal_v2/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
128
signal_v2/stock_client.py
Normal file
128
signal_v2/stock_client.py
Normal file
@@ -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}
|
||||||
18
signal_v2/tests/conftest.py
Normal file
18
signal_v2/tests/conftest.py
Normal file
@@ -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
|
||||||
168
signal_v2/tests/test_stock_client.py
Normal file
168
signal_v2/tests/test_stock_client.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user