- 스윙 체결가 확인 전 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
82 lines
3.3 KiB
Python
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)
|