Files
ai-trade/ai_trade/stock_client.py
gahusb 139e4e3382 refactor(web-ai): rename signal_v2→ai_trade, deprecate signal_v1
박재오 결정 2026-05-19 — V2를 정식 명칭 ai_trade로 graduation,
V1은 deprecated 마킹 (legacy 디렉토리 이동은 file lock 풀린 후 후속).

변경 사항:
- signal_v2/ → ai_trade/ (git mv, import 일괄 sed: signal_v2.x → ai_trade.x)
- root start.bat → legacy/start_v1.bat (V1 자동 시작 차단)
- ai_trade/start.bat 내부 uvicorn target signal_v2.main → ai_trade.main
- signal_v1/DEPRECATED.md 추가 (사용 금지 명시)
- CLAUDE.md 디렉토리 표·서버 시작 방식 갱신
- services/ 디렉토리 미래 예정 (Plan-B-Insta 작업 시 신설)

ai_trade tests 59/59 PASS 확인.

signal_v1/ 디렉토리 자체 이동(legacy/signal_v1/)은 telegram_bot.log +
data/news_snapshots.db file lock으로 보류. lock 해제 후 후속 커밋.

후속 작업: Plan-B-Insta (services/insta-render + NAS insta 분할)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:31:47 +09:00

130 lines
4.4 KiB
Python

"""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).
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
_TTL = {
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
}
# 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()
def cache_size(self) -> int:
"""Number of cached endpoint responses (public surface for /health)."""
return len(self._cache)
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 httpx.HTTPError:
# 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()
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:
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(2**attempt)
continue
raise
except httpx.HTTPStatusError:
raise
# Unreachable: every iteration either returns or raises
raise RuntimeError("_request_with_retry exhausted loop without raising")
def _auth_headers(self) -> dict[str, str]:
return {"X-WebAI-Key": self._api_key}