Files
web-page-backend/stock/app/screener/ai_news/analyzer.py
gahusb 36e8d11060 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
2026-07-02 14:38:51 +09:00

117 lines
4.2 KiB
Python

"""Claude Haiku 기반 종목 뉴스 호재/악재 분석."""
from __future__ import annotations
import datetime as dt
import json
import logging
import os
from typing import Any, Dict, List
log = logging.getLogger(__name__)
DEFAULT_MODEL = os.getenv("AI_NEWS_MODEL", "claude-haiku-4-5-20251001")
PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {n}개의 헤드라인입니다.
{news_block}
이 뉴스들이 종목 주가에 호재인지 악재인지 종합 평가하세요.
규칙:
- score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 명확한 방향성이 없으면 0(중립).
- 뉴스가 호재·악재로 섞여 있으면 주가에 더 우세한 쪽을 기준으로 부호를 정하세요.
- reason은 반드시 score 부호와 같은 방향의 근거만 쓰세요.
· score가 양수(호재)면 호재 근거만, 음수(악재)면 악재 근거만 적습니다.
· 호재 평가에 악재 내용을, 악재 평가에 호재 내용을 섞지 마세요.
- reason: 30자 이내 한 줄.
JSON으로만 응답하세요. 다른 텍스트 금지:
{{"score": <float>, "reason": "<string>"}}"""
def _clamp(x: float, lo: float = -10.0, hi: float = 10.0) -> float:
return max(lo, min(hi, x))
def _format_news_block(news: List[Dict[str, Any]]) -> str:
"""news dict 리스트 → prompt 에 들어가는 텍스트 블록.
summary 가 있으면 title 다음 줄에 indent 해서 포함 (최대 200자).
pub_date 가 있으면 title 앞에 표시.
"""
lines: List[str] = []
for n in news:
date = (n.get("pub_date") or "").strip()
title = (n.get("title") or "").strip()
summary = (n.get("summary") or "").strip()
prefix = f"[{date}] " if date else ""
if summary:
lines.append(f"- {prefix}{title}\n {summary[:200]}")
else:
lines.append(f"- {prefix}{title}")
return "\n".join(lines)
async def score_sentiment(
llm,
ticker: str,
news: List[Dict[str, Any]],
*,
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}.
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,
temperature=0,
system="너는 한국 주식 뉴스 감성 분석가다. JSON 객체 하나만 반환한다.",
messages=[
{"role": "user", "content": prompt},
# Assistant prefill — 첫 토큰을 강제로 '{' 로 시작해 JSON 응답을 보장
{"role": "assistant", "content": "{"},
],
)
raw = resp.content[0].text if resp.content else ""
# prefill '{' 이 응답에 포함되지 않으므로 다시 붙임
text = "{" + raw if not raw.lstrip().startswith("{") else raw
in_tokens = int(getattr(resp.usage, "input_tokens", 0) or 0)
out_tokens = int(getattr(resp.usage, "output_tokens", 0) or 0)
try:
data = json.loads(text)
score = _clamp(float(data["score"]))
reason = str(data["reason"])[:200]
return {
"ticker": ticker,
"score_raw": score,
"reason": reason,
"news_count": len(news),
"tokens_input": in_tokens,
"tokens_output": out_tokens,
"model": model,
}
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
log.warning("ai_news parse fail for %s: %s (raw=%r)", ticker, e, text[:100])
return {
"ticker": ticker,
"score_raw": 0.0,
"reason": f"parse fail: {e!s}"[:200],
"news_count": len(news),
"tokens_input": in_tokens,
"tokens_output": out_tokens,
"model": model,
}