Files
auto-stock/backend/tests/test_news_sentiment_round3_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

82 lines
3.3 KiB
Python

"""news_sentiment 라운드3: UTC 기사 나이 정규화, 0건 비캐시, 연속 0건 경보."""
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
import pytest
from app.core.config import settings
from app.core.timeutil import now_kst
from app.services import news_sentiment as ns
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
for st in ns._health.values():
st.update(consecutive_empty=0, last_success_at=None, alerted=False)
# 네트워크/Redis/DB 는 호출 즉시 예외 (목 누락 시 거짓 통과 방지)
def boom(*a, **k):
raise AssertionError("unmocked external call")
monkeypatch.setattr(ns, "get_json", AsyncMock(return_value=None))
monkeypatch.setattr(ns, "_save_news_to_db", boom)
yield
def test_utc_article_age_is_normalized_to_kst():
# 16시간 전 UTC 기사 -> 24h 이내 가중치 1.0 이어야 한다(구버그: 25h -> 0.6)
pub = (datetime.now(timezone.utc) - timedelta(hours=16)).isoformat().replace("+00:00", "Z")
recent = {"sentiment_score": -1.0, "published_at": pub}
fresh = {"sentiment_score": 1.0, "published_at": now_kst()}
# 둘 다 가중 1.0 이면 평균 0.0, 구버그면 (-0.6+1)/1.6=0.25
assert ns._compute_aggregate_score([recent, fresh]) == 0.0
def test_aware_datetime_object_normalized():
aware = datetime.now(timezone.utc) - timedelta(hours=16)
out = ns._to_kst_naive(aware)
assert out.tzinfo is None
assert abs((now_kst() - out).total_seconds() - 16 * 3600) < 5
def test_empty_stock_news_not_cached_and_counted(monkeypatch):
setex = AsyncMock()
monkeypatch.setattr(ns, "setex_json", setex)
monkeypatch.setattr(ns, "collect_stock_news", AsyncMock(return_value=[]))
assert asyncio.run(ns.get_stock_news_score("005930")) == 0.0
keys = [c.args[0] for c in setex.call_args_list]
assert "news_score:stock:005930" not in keys # 0.0 점수 캐시 금지
assert ns._health["stock"]["consecutive_empty"] == 1
def test_consecutive_empty_alerts_once(monkeypatch, caplog):
from app.services import telegram_notifier as tn
send = AsyncMock(return_value=True)
monkeypatch.setattr(tn.telegram_notifier, "send_message", send)
thr = int(getattr(settings, "NEWS_EMPTY_ALERT_THRESHOLD", ns._EMPTY_ALERT_DEFAULT))
async def run():
for _ in range(thr + 3):
await ns._record_collect_result("market", 0)
with caplog.at_level("ERROR"):
asyncio.run(run())
assert send.await_count == 1
assert any(r.levelname == "ERROR" for r in caplog.records)
def test_success_resets_and_records_time():
asyncio.run(ns._record_collect_result("stock", 0))
asyncio.run(ns._record_collect_result("stock", 3))
h = ns.get_news_collect_health()["stock"]
assert h["consecutive_empty"] == 0 and h["last_success_at"]
def test_stock_news_with_articles_is_cached(monkeypatch):
setex = AsyncMock()
monkeypatch.setattr(ns, "setex_json", setex)
monkeypatch.setattr(ns, "_save_news_to_db", AsyncMock())
arts = [{"title": "어닝쇼크", "sentiment_score": -0.9, "published_at": now_kst()}]
monkeypatch.setattr(ns, "collect_stock_news", AsyncMock(return_value=arts))
score = asyncio.run(ns.get_stock_news_score("005930"))
assert score <= settings.NEWS_BLOCK_SCORE
setex.assert_awaited_with("news_score:stock:005930", settings.NEWS_CACHE_TTL, score)