From 36e8d11060405118ca7e7db32329686b0c438e88 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 14:38:51 +0900 Subject: [PATCH] =?UTF-8?q?fix(stock):=20AI=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=ED=95=98=EB=A3=A8=20=EB=B0=80?= =?UTF-8?q?=EB=A6=BC=20=ED=95=B4=EC=86=8C=20=E2=80=94=20asof=EB=A5=BC=20KS?= =?UTF-8?q?T=EB=A1=9C=20=EB=B3=B4=EC=A0=95=20+=20LLM=EC=97=90=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EC=9D=BC=EC=9E=90=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 근본원인: 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) Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9 --- stock/app/screener/ai_news/analyzer.py | 9 ++++++++- stock/app/screener/ai_news/pipeline.py | 6 +++--- stock/app/screener/router.py | 14 ++++++++++++-- stock/tests/test_ai_news_analyzer.py | 12 ++++++++++++ stock/tests/test_ai_news_pipeline.py | 10 +++++----- stock/tests/test_ai_news_router.py | 15 +++++++++++++++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/stock/app/screener/ai_news/analyzer.py b/stock/app/screener/ai_news/analyzer.py index 7dcbca8..3e1b48d 100644 --- a/stock/app/screener/ai_news/analyzer.py +++ b/stock/app/screener/ai_news/analyzer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as dt import json import logging import os @@ -59,13 +60,19 @@ async def score_sentiment( *, name: str | None = None, model: str = DEFAULT_MODEL, + asof: dt.date | None = None, ) -> 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) prompt = PROMPT_TEMPLATE.format( name=name or ticker, ticker=ticker, n=len(news), news_block=news_block, ) + if asof is not None: + prompt = f"오늘 날짜: {asof.isoformat()} (이 시점 기준으로 뉴스를 평가하세요)\n\n" + prompt resp = await llm.messages.create( model=model, max_tokens=200, diff --git a/stock/app/screener/ai_news/pipeline.py b/stock/app/screener/ai_news/pipeline.py index a8922b5..7e67853 100644 --- a/stock/app/screener/ai_news/pipeline.py +++ b/stock/app/screener/ai_news/pipeline.py @@ -39,11 +39,11 @@ def _make_llm(): async def _process_one( 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]: async with sem: 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, []) if not arts: 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) successes: List[Dict[str, Any]] = [] diff --git a/stock/app/screener/router.py b/stock/app/screener/router.py index 66994f6..6c2907a 100644 --- a/stock/app/screener/router.py +++ b/stock/app/screener/router.py @@ -125,6 +125,16 @@ from . import telegram as _tg 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: if asof_str: return dt.date.fromisoformat(asof_str) @@ -263,7 +273,7 @@ from . import snapshot as _snap @router.post("/snapshot/refresh") 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: return {"asof": asof_date.isoformat(), "status": "skipped_weekend"} with _conn() as c: @@ -300,7 +310,7 @@ from .ai_news import validation as _ai_validation @router.post("/snapshot/refresh-news-sentiment") 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: return {"asof": asof_date.isoformat(), "status": "skipped_weekend"} if _is_holiday(asof_date): diff --git a/stock/tests/test_ai_news_analyzer.py b/stock/tests/test_ai_news_analyzer.py index 0ab4c09..94963cf 100644 --- a/stock/tests/test_ai_news_analyzer.py +++ b/stock/tests/test_ai_news_analyzer.py @@ -58,6 +58,18 @@ async def test_score_sentiment_clamps_negative_out_of_range(): 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 async def test_score_sentiment_includes_summary_in_prompt(): """summary 가 있으면 prompt 에 포함, 없으면 title 만.""" diff --git a/stock/tests/test_ai_news_pipeline.py b/stock/tests/test_ai_news_pipeline.py index 8115565..422828f 100644 --- a/stock/tests/test_ai_news_pipeline.py +++ b/stock/tests/test_ai_news_pipeline.py @@ -39,7 +39,7 @@ async def test_refresh_daily_happy_path(conn): scores_by_ticker = { "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 { "ticker": ticker, "score_raw": scores_by_ticker[ticker], "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} - 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": raise RuntimeError("llm exploded") 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} - 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 { "ticker": ticker, "score_raw": 5.0, "reason": "r", "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} 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 { "ticker": ticker, "score_raw": scores[ticker], "reason": "r", "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} 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 { "ticker": ticker, "score_raw": scores[ticker], "reason": "r", "news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model, diff --git a/stock/tests/test_ai_news_router.py b/stock/tests/test_ai_news_router.py index b4c28eb..e61b2c0 100644 --- a/stock/tests/test_ai_news_router.py +++ b/stock/tests/test_ai_news_router.py @@ -5,6 +5,21 @@ from fastapi.testclient import TestClient 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(): # 2026-05-16 = Saturday client = TestClient(app)