fix(stock): AI 뉴스 리포트 하루 밀림 해소 — asof를 KST로 보정 + LLM에 현재 일자 주입

근본원인: stock 컨테이너는 python:3.12-alpine + tzdata 미설치라 TZ=Asia/Seoul이
무효 → date.today()가 UTC를 반환. AI 뉴스 리포트 cron은 08:00 KST(=전날 23:00 UTC)라
asof가 어제로 계산돼 라벨·기사 윈도우·news_sentiment 저장이 전부 하루 밀렸음
(월요일은 일요일 UTC로 계산돼 skip_weekend까지).

- screener/router.py: _today_kst()(=utcnow+9h, holdings_intel 관용) 추가.
  /snapshot/refresh · /snapshot/refresh-news-sentiment의 asof 기본값을 KST로.
- ai_news/analyzer.py: score_sentiment(asof=...) → 프롬프트 앞에 "오늘 날짜" 명시,
  LLM이 현재 일자 기준으로 뉴스 평가(사용자 요청).
- ai_news/pipeline.py: refresh_daily가 asof를 score_sentiment까지 스레딩.
- 테스트: _today_kst KST 보정 + analyzer asof 주입 2종 TDD Red→Green.
  기존 pipeline 목 시그니처에 asof 반영. stock 전체 149 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
2026-07-02 14:38:51 +09:00
parent db6fed72b3
commit 36e8d11060
6 changed files with 55 additions and 11 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import datetime as dt
import json import json
import logging import logging
import os import os
@@ -59,13 +60,19 @@ async def score_sentiment(
*, *,
name: str | None = None, name: str | None = None,
model: str = DEFAULT_MODEL, model: str = DEFAULT_MODEL,
asof: dt.date | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Returns {ticker, score_raw, reason, news_count, tokens_input, tokens_output, model}.""" """Returns {ticker, score_raw, reason, news_count, tokens_input, tokens_output, model}.
asof(현재 KST 일자)를 주면 prompt 맨 앞에 오늘 날짜를 명시해 LLM이 현재 시점 기준으로 판단한다.
"""
news_block = _format_news_block(news) news_block = _format_news_block(news)
prompt = PROMPT_TEMPLATE.format( prompt = PROMPT_TEMPLATE.format(
name=name or ticker, ticker=ticker, name=name or ticker, ticker=ticker,
n=len(news), news_block=news_block, n=len(news), news_block=news_block,
) )
if asof is not None:
prompt = f"오늘 날짜: {asof.isoformat()} (이 시점 기준으로 뉴스를 평가하세요)\n\n" + prompt
resp = await llm.messages.create( resp = await llm.messages.create(
model=model, model=model,
max_tokens=200, max_tokens=200,

View File

@@ -39,11 +39,11 @@ def _make_llm():
async def _process_one( async def _process_one(
ticker: str, name: str, articles: List[Dict[str, Any]], ticker: str, name: str, articles: List[Dict[str, Any]],
sem: asyncio.Semaphore, llm, model: str, sem: asyncio.Semaphore, llm, model: str, asof: dt.date,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
async with sem: async with sem:
return await _analyzer.score_sentiment( return await _analyzer.score_sentiment(
llm, ticker, articles, name=name, model=model, llm, ticker, articles, name=name, model=model, asof=asof,
) )
@@ -110,7 +110,7 @@ async def refresh_daily(
arts = articles_by_ticker.get(t, []) arts = articles_by_ticker.get(t, [])
if not arts: if not arts:
continue # 매핑 0 — score 미생성 continue # 매핑 0 — score 미생성
tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model)) tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model, asof))
raw_results = await asyncio.gather(*tasks, return_exceptions=True) raw_results = await asyncio.gather(*tasks, return_exceptions=True)
successes: List[Dict[str, Any]] = [] successes: List[Dict[str, Any]] = []

View File

@@ -125,6 +125,16 @@ from . import telegram as _tg
from .engine import Screener, ScreenContext from .engine import Screener, ScreenContext
def _today_kst() -> dt.date:
"""KST 오늘 날짜.
stock 컨테이너는 python:3.12-alpine + tzdata 미설치라 TZ=Asia/Seoul이 무효 →
date.today()가 UTC를 반환한다. 08시대(KST) 리포트가 하루 밀리는 것을 막기 위해
UTC+9로 명시 보정한다(holdings_intel._today_kst와 동일한 관용).
"""
return (dt.datetime.utcnow() + dt.timedelta(hours=9)).date()
def _resolve_asof(asof_str, conn: sqlite3.Connection) -> dt.date: def _resolve_asof(asof_str, conn: sqlite3.Connection) -> dt.date:
if asof_str: if asof_str:
return dt.date.fromisoformat(asof_str) return dt.date.fromisoformat(asof_str)
@@ -263,7 +273,7 @@ from . import snapshot as _snap
@router.post("/snapshot/refresh") @router.post("/snapshot/refresh")
def post_snapshot_refresh(asof: Optional[str] = None): def post_snapshot_refresh(asof: Optional[str] = None):
asof_date = dt.date.fromisoformat(asof) if asof else dt.date.today() asof_date = dt.date.fromisoformat(asof) if asof else _today_kst()
if asof_date.weekday() >= 5: if asof_date.weekday() >= 5:
return {"asof": asof_date.isoformat(), "status": "skipped_weekend"} return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
with _conn() as c: with _conn() as c:
@@ -300,7 +310,7 @@ from .ai_news import validation as _ai_validation
@router.post("/snapshot/refresh-news-sentiment") @router.post("/snapshot/refresh-news-sentiment")
async def post_refresh_news_sentiment(asof: Optional[str] = None): async def post_refresh_news_sentiment(asof: Optional[str] = None):
asof_date = dt.date.fromisoformat(asof) if asof else dt.date.today() asof_date = dt.date.fromisoformat(asof) if asof else _today_kst()
if asof_date.weekday() >= 5: if asof_date.weekday() >= 5:
return {"asof": asof_date.isoformat(), "status": "skipped_weekend"} return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
if _is_holiday(asof_date): if _is_holiday(asof_date):

View File

@@ -58,6 +58,18 @@ async def test_score_sentiment_clamps_negative_out_of_range():
assert out["score_raw"] == -10.0 assert out["score_raw"] == -10.0
@pytest.mark.asyncio
async def test_score_sentiment_includes_asof_date_in_prompt():
"""asof(현재 KST 일자)를 넘기면 prompt에 오늘 날짜가 포함되어 LLM이 현재 일자 기준으로 판단."""
import datetime as _dt
llm = _mk_llm(json.dumps({"score": 5.0, "reason": "ok"}))
await analyzer.score_sentiment(
llm, "005930", NEWS, name="삼성전자", asof=_dt.date(2026, 7, 2),
)
user_msg = llm.messages.create.call_args.kwargs["messages"][0]["content"]
assert "2026-07-02" in user_msg
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_score_sentiment_includes_summary_in_prompt(): async def test_score_sentiment_includes_summary_in_prompt():
"""summary 가 있으면 prompt 에 포함, 없으면 title 만.""" """summary 가 있으면 prompt 에 포함, 없으면 title 만."""

View File

@@ -39,7 +39,7 @@ async def test_refresh_daily_happy_path(conn):
scores_by_ticker = { scores_by_ticker = {
"005930": 7.5, "000660": 4.0, "373220": -6.0, "005930": 7.5, "000660": 4.0, "373220": -6.0,
} }
async def fake_score(llm, ticker, news, *, name=None, model="m"): async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
return { return {
"ticker": ticker, "score_raw": scores_by_ticker[ticker], "ticker": ticker, "score_raw": scores_by_ticker[ticker],
"reason": f"r{ticker}", "news_count": 1, "reason": f"r{ticker}", "news_count": 1,
@@ -81,7 +81,7 @@ async def test_refresh_daily_failures_isolated(conn):
} }
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3} fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
async def fake_score(llm, ticker, news, *, name=None, model="m"): async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
if ticker == "000660": if ticker == "000660":
raise RuntimeError("llm exploded") raise RuntimeError("llm exploded")
return { return {
@@ -116,7 +116,7 @@ async def test_refresh_daily_no_match_ticker_skipped(conn):
} }
fake_stats = {"total_articles": 1, "matched_pairs": 1, "hit_tickers": 1} fake_stats = {"total_articles": 1, "matched_pairs": 1, "hit_tickers": 1}
async def fake_score(llm, ticker, news, *, name=None, model="m"): async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
return { return {
"ticker": ticker, "score_raw": 5.0, "reason": "r", "ticker": ticker, "score_raw": 5.0, "reason": "r",
"news_count": 1, "tokens_input": 100, "tokens_output": 20, "news_count": 1, "tokens_input": 100, "tokens_output": 20,
@@ -152,7 +152,7 @@ async def test_refresh_daily_sign_gate_no_positive_in_neg(conn):
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3} fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
scores = {"005930": 6.0, "000660": 2.0, "373220": 0.5} # 모두 양수 scores = {"005930": 6.0, "000660": 2.0, "373220": 0.5} # 모두 양수
async def fake_score(llm, ticker, news, *, name=None, model="m"): async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
return { return {
"ticker": ticker, "score_raw": scores[ticker], "reason": "r", "ticker": ticker, "score_raw": scores[ticker], "reason": "r",
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model, "news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
@@ -183,7 +183,7 @@ async def test_refresh_daily_sign_gate_excludes_neutral(conn):
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3} fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
scores = {"005930": 3.0, "000660": 0.0, "373220": -3.0} scores = {"005930": 3.0, "000660": 0.0, "373220": -3.0}
async def fake_score(llm, ticker, news, *, name=None, model="m"): async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
return { return {
"ticker": ticker, "score_raw": scores[ticker], "reason": "r", "ticker": ticker, "score_raw": scores[ticker], "reason": "r",
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model, "news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,

View File

@@ -5,6 +5,21 @@ from fastapi.testclient import TestClient
from app.main import app from app.main import app
def test_today_kst_uses_kst_offset_not_utc(monkeypatch):
"""컨테이너가 UTC(Alpine, tzdata 미설치)라 date.today()는 08시 KST에 어제를 준다.
_today_kst()는 UTC+9로 보정해 오늘(KST)을 반환해야 한다."""
from app.screener import router
class _FrozenDT(dt.datetime):
@classmethod
def utcnow(cls):
# 2026-07-01 23:30 UTC == 2026-07-02 08:30 KST (AI 뉴스 리포트 시각대)
return dt.datetime(2026, 7, 1, 23, 30, 0)
monkeypatch.setattr(router.dt, "datetime", _FrozenDT)
assert router._today_kst() == dt.date(2026, 7, 2)
def test_refresh_news_sentiment_weekend_skip(): def test_refresh_news_sentiment_weekend_skip():
# 2026-05-16 = Saturday # 2026-05-16 = Saturday
client = TestClient(app) client = TestClient(app)