Files
ai-trade/ai_trade/tests/test_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

169 lines
5.2 KiB
Python

"""Tests for stock_client.StockClient."""
import asyncio
import logging
import pytest
import httpx
from ai_trade.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):
"""180s 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 past portfolio TTL (180s) between calls
fake_time = [0.0]
monkeypatch.setattr(
"ai_trade.stock_client.time.monotonic", lambda: fake_time[0]
)
client = StockClient(BASE_URL, API_KEY)
try:
await client.get_portfolio()
fake_time[0] = 181.0 # 180s 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(
"ai_trade.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 (180s) so cache is stale
fake_time[0] = 181.0
# Now mock to return 500s persistently
route1.mock(return_value=httpx.Response(500, text="server error"))
with caplog.at_level(logging.WARNING, logger="ai_trade.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()