docs(screener): AI news sentiment node design spec
8번째 점수 노드 ai_news 설계. 평일 08:00 KST agent-office cron 으로 시총 상위 100종목 네이버 뉴스 스크래핑 + Claude Haiku 호재/악재 분석, news_sentiment 일별 저장, 호재/악재 Top 5 텔레그램 알림, 16:30 스크리너 잡이 percentile_rank 로 가중합에 활용. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,558 @@
|
|||||||
|
# AI News Sentiment Node — Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-13
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — AI 뉴스 호재/악재 노드 후속 슬라이스)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
스크리너의 8번째 점수 노드 `AiNewsSentiment` 를 추가한다. 평일 **08:00 KST** 에 시총 상위 100종목의 네이버 종목 뉴스를 스크래핑하고 Claude Haiku로 호재/악재를 정량화하여, 그날의 sentiment를 (a) 텔레그램으로 호재/악재 Top 5 알림으로 전달하고, (b) 16:30 스크리너 자동 잡의 가중합에 percentile_rank 형태로 기여한다.
|
||||||
|
|
||||||
|
**Why**: 기존 7개 점수 노드는 모두 수치 기반(가격/거래량/외국인 수급)이며, 시장 정서(뉴스 호재/악재)는 반영되지 않는다. 트레이더 의사결정에 큰 영향을 주는 호재/악재 시그널을 LLM으로 정량화하면 정량 노드와 정성 노드를 한 점수 체계로 통합할 수 있다. 장 시작 전 알림으로 즉시 가치 전달.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
**포함 (이번 슬라이스)**:
|
||||||
|
- 평일 08:00 KST agent-office cron → stock-lab `/snapshot/refresh-news-sentiment` 호출
|
||||||
|
- 시총 상위 100종목 × 네이버 종목 뉴스 (`/item/news_news.naver?code=XXX`) 스크래핑
|
||||||
|
- 종목당 Claude Haiku 1콜 (총 100콜 asyncio.gather 병렬, 동시성 10)
|
||||||
|
- `news_sentiment(ticker, date, score_raw, reason, news_count, tokens_input, tokens_output, model, created_at)` 테이블
|
||||||
|
- 8번째 ScoreNode `AiNewsSentiment` 등록 (registry, schema, ScreenContext, 가중합 통합)
|
||||||
|
- 호재 Top 5 + 악재 Top 5 텔레그램 메시지 (장 시작 전 발송)
|
||||||
|
- 프론트 캔버스 모드에 8번째 노드 추가 (`SCORE_KEYS` 한 줄 + `INITIAL_NODE_POSITIONS` 좌표 한 줄)
|
||||||
|
|
||||||
|
**범위 외 (NOT)**:
|
||||||
|
- 뉴스 URL 단위 캐싱 (비용이 충분히 낮음)
|
||||||
|
- 16:00 추가 cron (MVP 일 1회)
|
||||||
|
- 시장 전체 뉴스 종목 매핑 LLM (시총 상위 100 명시적 매핑)
|
||||||
|
- 백테스트 (sentiment 점수가 실수익에 미친 영향) — 별도 후속 슬라이스
|
||||||
|
- 가중치 자동 조정 — spec §14 별도 슬라이스
|
||||||
|
- 종목별 sentiment 트렌드 차트 — 데이터 누적 후 후속 슬라이스
|
||||||
|
- 종목 5-10위 외 sentiment 가시화 — Top 5 알림 외 별도 대시보드 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
[08:00 KST 평일] │ agent-office cron │
|
||||||
|
│ on_ai_news_schedule() │
|
||||||
|
└──────────────┬──────────────┘
|
||||||
|
│ HTTP POST
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ stock-lab: /api/stock/screener/snapshot/ │
|
||||||
|
│ refresh-news-sentiment │
|
||||||
|
│ │
|
||||||
|
│ ai_news/pipeline.refresh_daily(asof): │
|
||||||
|
│ 1. krx_master 시총 상위 100 ticker 조회 │
|
||||||
|
│ 2. asyncio.gather(Semaphore=10) 100 종목 병렬: │
|
||||||
|
│ a. scraper.fetch_news(ticker) │
|
||||||
|
│ b. analyzer.score_sentiment(ticker, news[]) │
|
||||||
|
│ c. → {score: float, reason: str, ...} │
|
||||||
|
│ 3. news_sentiment 일괄 upsert │
|
||||||
|
│ 4. Top 5 호재/악재 추출 → 텔레그램 페이로드 빌드 │
|
||||||
|
│ 5. agent-office /telegram/send 호출 │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[16:30 KST 평일] ┌─────────────────────────────┐
|
||||||
|
│ agent-office on_screener_ │
|
||||||
|
│ schedule (변경 없음) │
|
||||||
|
└──────────────┬──────────────┘
|
||||||
|
│ HTTP POST
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ stock-lab: /api/stock/screener/run mode=auto │
|
||||||
|
│ │
|
||||||
|
│ Screener.run(ctx): │
|
||||||
|
│ ctx.news_sentiment = SELECT * FROM news_sentiment │
|
||||||
|
│ WHERE date = asof │
|
||||||
|
│ AiNewsSentiment.compute(ctx, params) │
|
||||||
|
│ → percentile_rank(score_raw) for 100 tickers │
|
||||||
|
│ → 가중합에 ai_news weight × percentile 점수 기여 │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**의존성 추가**: `anthropic` Python SDK (stock-lab requirements.txt). `ANTHROPIC_API_KEY` 는 docker-compose.yml에 이미 stock-lab 환경변수로 존재.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 컴포넌트 분해 (신규 파일)
|
||||||
|
|
||||||
|
### 4.1 stock-lab
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/
|
||||||
|
screener/
|
||||||
|
ai_news/ ← 신규 모듈
|
||||||
|
__init__.py
|
||||||
|
scraper.py ← 네이버 finance 종목 뉴스 스크래핑
|
||||||
|
analyzer.py ← Claude Haiku 호재/악재 분석
|
||||||
|
pipeline.py ← refresh_daily() 메인 (스크래핑+병렬 LLM+DB upsert)
|
||||||
|
telegram.py ← Top 5/Top 5 메시지 빌더
|
||||||
|
nodes/
|
||||||
|
ai_news.py ← 8번째 ScoreNode 클래스
|
||||||
|
schema.py ← (수정) news_sentiment 테이블 DDL 추가
|
||||||
|
registry.py ← (수정) NODE_REGISTRY["ai_news"] 등록
|
||||||
|
engine.py ← (수정) ScreenContext에 news_sentiment 로딩
|
||||||
|
router.py ← (수정) POST /snapshot/refresh-news-sentiment 라우트 추가
|
||||||
|
requirements.txt ← (수정) anthropic 추가
|
||||||
|
tests/
|
||||||
|
test_ai_news_scraper.py ← 네이버 HTML mock 파싱
|
||||||
|
test_ai_news_analyzer.py ← anthropic mock 응답
|
||||||
|
test_ai_news_pipeline.py ← 5종목 mini integration
|
||||||
|
test_ai_news_node.py ← percentile_rank + min_news_count 필터
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 agent-office
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend/agent-office/app/
|
||||||
|
agents/stock.py ← (수정) on_ai_news_schedule 메서드 추가
|
||||||
|
scheduler.py ← (수정) cron mon-fri 08:00 등록
|
||||||
|
service_proxy.py ← (수정) refresh_ai_news_sentiment() helper 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ui/src/pages/stock/screener/
|
||||||
|
components/canvas/constants/
|
||||||
|
canvasLayout.js ← (수정) AI 노드 추가 (NODE_IDS / NAME_MAP / LABEL / POSITIONS / SCORE_KEYS)
|
||||||
|
canvasLayout.test.js ← (수정) 카운트 8 점수 노드, 18 엣지로 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DB 스키마 (1개 신규 테이블)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS news_sentiment (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||||
|
score_raw REAL NOT NULL, -- LLM 원점수 -10 ~ +10
|
||||||
|
reason TEXT NOT NULL DEFAULT '', -- LLM 한 줄 근거
|
||||||
|
news_count INTEGER NOT NULL DEFAULT 0, -- 분석에 사용된 뉴스 수
|
||||||
|
tokens_input INTEGER NOT NULL DEFAULT 0, -- 비용 모니터링
|
||||||
|
tokens_output INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
PRIMARY KEY (ticker, date)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_news_sentiment_date ON news_sentiment(date DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
`schema.py` 의 `ensure_screener_schema(conn)` 함수에 이 DDL 추가. WAL + busy_timeout 패턴은 stock-lab `_conn()` 표준화로 이미 적용됨.
|
||||||
|
|
||||||
|
**기본 가중치 시드**: `DEFAULT_WEIGHTS["ai_news"] = 0.5` 추가 (다른 7노드의 default와 동일). 기존 settings 행이 있는 환경에서는 마이그레이션 1회 — `ensure_screener_schema()` 가 settings의 weights_json에 ai_news 키 누락 시 0.5로 보충하는 1회성 patch 적용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ScoreNode 구현
|
||||||
|
|
||||||
|
```python
|
||||||
|
# stock-lab/app/screener/nodes/ai_news.py
|
||||||
|
import pandas as pd
|
||||||
|
from .base import ScoreNode, percentile_rank
|
||||||
|
|
||||||
|
class AiNewsSentiment(ScoreNode):
|
||||||
|
name = "ai_news"
|
||||||
|
label = "AI 뉴스 호재/악재"
|
||||||
|
default_params = {"min_news_count": 1}
|
||||||
|
param_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"min_news_count": {
|
||||||
|
"type": "integer", "default": 1, "minimum": 0,
|
||||||
|
"description": "최소 분석 뉴스 수. 미만이면 NaN.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute(self, ctx, params):
|
||||||
|
df = getattr(ctx, "news_sentiment", None)
|
||||||
|
if df is None or df.empty:
|
||||||
|
return pd.Series(dtype=float)
|
||||||
|
df = df[df["news_count"] >= params["min_news_count"]]
|
||||||
|
if df.empty:
|
||||||
|
return pd.Series(dtype=float)
|
||||||
|
return percentile_rank(df.set_index("ticker")["score_raw"])
|
||||||
|
```
|
||||||
|
|
||||||
|
`ScreenContext` dataclass에 `news_sentiment: Optional[pd.DataFrame] = None` 필드 추가 (default None 으로 기존 호출자 호환성 유지). `ScreenContext.load(conn, asof)` 에 로딩 한 줄 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
news_sentiment = pd.read_sql_query(
|
||||||
|
"SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
|
||||||
|
conn, params=(asof.isoformat(),),
|
||||||
|
)
|
||||||
|
return ScreenContext(..., news_sentiment=news_sentiment)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 테스트 fixture에서 `ScreenContext(...)` 를 직접 생성하는 케이스는 default=None 으로 자동 호환. AiNewsSentiment.compute() 는 `getattr(ctx, "news_sentiment", None)` 로 안전 fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 파이프라인 (`ai_news/pipeline.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def refresh_daily(conn, asof, *, tickers=None, model=DEFAULT_MODEL,
|
||||||
|
concurrency=10, news_per_ticker=5):
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
{"asof": ..., "updated": N, "failures": [...], "duration_sec": ...,
|
||||||
|
"tokens_input": ..., "tokens_output": ..., "top_pos": [...], "top_neg": [...]}
|
||||||
|
"""
|
||||||
|
if tickers is None:
|
||||||
|
tickers = _top_market_cap_tickers(conn, n=100)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
async with httpx.AsyncClient(...) as http_client, AsyncAnthropic(...) as llm:
|
||||||
|
tasks = [_process_ticker(t, sem, http_client, llm, news_per_ticker, model)
|
||||||
|
for t in tickers]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
successes = [r for r in results if isinstance(r, dict)]
|
||||||
|
failures = [r for r in results if isinstance(r, BaseException)]
|
||||||
|
|
||||||
|
_upsert_news_sentiment(conn, asof, successes)
|
||||||
|
|
||||||
|
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
||||||
|
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"asof": asof.isoformat(),
|
||||||
|
"updated": len(successes),
|
||||||
|
"failures": [str(e) for e in failures],
|
||||||
|
"duration_sec": ...,
|
||||||
|
"tokens_input": sum(r["tokens_input"] for r in successes),
|
||||||
|
"tokens_output": sum(r["tokens_output"] for r in successes),
|
||||||
|
"top_pos": top_pos,
|
||||||
|
"top_neg": top_neg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_ticker(ticker, sem, http_client, llm, news_per_ticker, model):
|
||||||
|
async with sem:
|
||||||
|
await asyncio.sleep(0.2) # rate limit
|
||||||
|
news = await scraper.fetch_news(http_client, ticker, n=news_per_ticker)
|
||||||
|
if not news:
|
||||||
|
return {"ticker": ticker, "score_raw": 0.0,
|
||||||
|
"reason": "no news", "news_count": 0,
|
||||||
|
"tokens_input": 0, "tokens_output": 0}
|
||||||
|
return await analyzer.score_sentiment(llm, ticker, news, model=model)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Scraper (`ai_news/scraper.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
NAVER_NEWS_URL = "https://finance.naver.com/item/news_news.naver"
|
||||||
|
|
||||||
|
async def fetch_news(client, ticker, n=5):
|
||||||
|
r = await client.get(NAVER_NEWS_URL, params={"code": ticker, "page": 1})
|
||||||
|
if r.status_code != 200:
|
||||||
|
return []
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
rows = soup.select("table.type5 tbody tr")[:n]
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
title_el = row.select_one("td.title a")
|
||||||
|
date_el = row.select_one("td.date")
|
||||||
|
if not title_el or not date_el:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"title": title_el.get_text(strip=True),
|
||||||
|
"date": date_el.get_text(strip=True),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
Rate limit: pipeline 의 `Semaphore(10) + 0.2초 sleep` 으로 보호.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Analyzer (`ai_news/analyzer.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
DEFAULT_MODEL = os.getenv("AI_NEWS_MODEL", "claude-haiku-4-5-20251001")
|
||||||
|
|
||||||
|
PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {n}개의 헤드라인입니다.
|
||||||
|
|
||||||
|
{news_block}
|
||||||
|
|
||||||
|
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
|
||||||
|
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
|
||||||
|
reason: 30자 이내 한 줄 근거.
|
||||||
|
|
||||||
|
JSON으로만 응답하세요:
|
||||||
|
{{"score": <float>, "reason": "<string>"}}"""
|
||||||
|
|
||||||
|
async def score_sentiment(llm, ticker, news, *, model=DEFAULT_MODEL, name=None):
|
||||||
|
news_block = "\n".join(f"- {n['title']}" for n in news)
|
||||||
|
prompt = PROMPT_TEMPLATE.format(
|
||||||
|
name=name or ticker, ticker=ticker,
|
||||||
|
n=len(news), news_block=news_block,
|
||||||
|
)
|
||||||
|
resp = await llm.messages.create(
|
||||||
|
model=model, max_tokens=200,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
text = resp.content[0].text
|
||||||
|
data = json.loads(text)
|
||||||
|
return {
|
||||||
|
"ticker": ticker,
|
||||||
|
"score_raw": float(data["score"]),
|
||||||
|
"reason": str(data["reason"])[:200],
|
||||||
|
"news_count": len(news),
|
||||||
|
"tokens_input": resp.usage.input_tokens,
|
||||||
|
"tokens_output": resp.usage.output_tokens,
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||||
|
log.warning("ai_news parse fail for %s: %s", ticker, e)
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": 0.0,
|
||||||
|
"reason": f"parse fail: {e!s}",
|
||||||
|
"news_count": len(news),
|
||||||
|
"tokens_input": resp.usage.input_tokens,
|
||||||
|
"tokens_output": resp.usage.output_tokens,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 텔레그램 메시지 (`ai_news/telegram.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_telegram_payload(*, asof, top_pos, top_neg,
|
||||||
|
tokens_input, tokens_output, model):
|
||||||
|
cost_won = int(tokens_input * 0.0013 + tokens_output * 0.0065) # ₩ 환산
|
||||||
|
lines = [
|
||||||
|
f"🌅 *AI 뉴스 분석* ({asof} 08:00)",
|
||||||
|
"",
|
||||||
|
"📈 *호재 Top 5*",
|
||||||
|
]
|
||||||
|
for i, r in enumerate(top_pos, 1):
|
||||||
|
lines.append(
|
||||||
|
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||||
|
f"{_escape(r['reason'])}"
|
||||||
|
)
|
||||||
|
lines += ["", "📉 *악재 Top 5*"]
|
||||||
|
for i, r in enumerate(top_neg, 1):
|
||||||
|
lines.append(
|
||||||
|
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||||
|
f"{_escape(r['reason'])}"
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
|
||||||
|
f"약 ₩{cost_won:,}_",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
agent-office 가 텔레그램 발송 책임: stock-lab `/refresh-news-sentiment` 응답을 받아 `messaging.send_raw(text, parse_mode="MarkdownV2")` 호출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. agent-office 통합
|
||||||
|
|
||||||
|
### 11.1 `agents/stock.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def on_ai_news_schedule(self):
|
||||||
|
"""평일 08:00 KST cron."""
|
||||||
|
try:
|
||||||
|
result = await service_proxy.refresh_ai_news_sentiment()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
await self.telegram.send_raw(f"⚠️ AI 뉴스 분석 실패: {e!s}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if result.get("updated", 0) == 0:
|
||||||
|
await self.telegram.send_raw("⚠️ AI 뉴스: 0종목 분석됨 (스크래핑/LLM 전체 실패)")
|
||||||
|
return
|
||||||
|
|
||||||
|
failure_rate = len(result.get("failures", [])) / 100
|
||||||
|
if failure_rate > 0.3:
|
||||||
|
await self.telegram.send_raw(
|
||||||
|
f"⚠️ AI 뉴스 실패율 {failure_rate:.0%} — 어제 데이터 사용 가능성"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = build_telegram_payload(
|
||||||
|
asof=result["asof"],
|
||||||
|
top_pos=result["top_pos"], top_neg=result["top_neg"],
|
||||||
|
tokens_input=result["tokens_input"],
|
||||||
|
tokens_output=result["tokens_output"],
|
||||||
|
model=DEFAULT_MODEL,
|
||||||
|
)
|
||||||
|
await self.telegram.send_raw(payload, parse_mode="MarkdownV2")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 `scheduler.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
scheduler.add_job(
|
||||||
|
stock_agent.on_ai_news_schedule,
|
||||||
|
"cron", day_of_week="mon-fri", hour=8, minute=0,
|
||||||
|
id="stock_ai_news_sentiment",
|
||||||
|
timezone="Asia/Seoul",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 에러 처리
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| 네이버 뉴스 페이지 404/타임아웃 | 해당 종목 score_raw=0 + reason="no news", failures 별도 카운트 |
|
||||||
|
| BeautifulSoup 파싱 실패 (HTML 구조 변경) | 동일 처리 (failures 카운트) |
|
||||||
|
| LLM JSON 파싱 실패 | score_raw=0 + reason="parse fail", tokens는 그래도 누적 (실제 호출됨) |
|
||||||
|
| anthropic API 5xx | 자동 retry 1회 (SDK 기본), 실패 시 failures 카운트 |
|
||||||
|
| 전체 cron 실패 (네트워크 등) | agent-office 에러 텔레그램 + 16:30 잡은 어제 sentiment 데이터 사용 (date 비교로 자동) |
|
||||||
|
| 실패율 > 30% | 텔레그램 경고 알림. 단 부분 데이터는 그대로 DB 반영 |
|
||||||
|
| 16:30 시점 news_sentiment 비어 있음 | AiNewsSentiment.compute() 가 빈 Series 반환 → 가중합에서 이 노드 자동 제외 |
|
||||||
|
| LLM이 -10/+10 범위 벗어난 값 응답 | clamp `max(-10, min(10, score))` 적용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 동시성 & rate limit
|
||||||
|
|
||||||
|
- `asyncio.Semaphore(10)` — 동시 10종목 처리 (네이버 차단 회피)
|
||||||
|
- 종목 처리 사이 0.2초 sleep (semaphore 안에서)
|
||||||
|
- 100종목 ÷ 10 동시 × 평균 3초/종목 = **~30-60초 총 소요**
|
||||||
|
- agent-office httpx timeout = 180초 (충분한 여유)
|
||||||
|
- stock-lab _conn() 의 WAL + busy_timeout=120s 로 16:30 잡과 동시 실행 시 lock 보호
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 비용 모니터링
|
||||||
|
|
||||||
|
- 종목당 평균: input ~500 tokens, output ~50 tokens
|
||||||
|
- 일 비용: 50K input × $1/M + 5K output × $5/M = **$0.075/일**
|
||||||
|
- 월 비용: **~$1.6** (텔레그램 메시지 하단에 매일 ₩72 형태로 표시)
|
||||||
|
- `news_sentiment.tokens_input/output` 컬럼으로 누적 추적 가능
|
||||||
|
- 환산: 1 USD ≈ ₩1,300, input $0.0013/K, output $0.0065/K (장기 평균)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 프론트엔드 변경
|
||||||
|
|
||||||
|
캔버스 모드에 8번째 점수 노드 추가. 아래 한 파일만 수정:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// canvasLayout.js
|
||||||
|
export const NODE_IDS = {
|
||||||
|
...,
|
||||||
|
AI_NEWS: 'score-ai-news', // 신규
|
||||||
|
...,
|
||||||
|
};
|
||||||
|
export const NODE_KIND_MAP = { ..., [NODE_IDS.AI_NEWS]: 'score', ... };
|
||||||
|
export const SCORE_NODE_NAME_MAP = { ..., [NODE_IDS.AI_NEWS]: 'ai_news' };
|
||||||
|
export const SCORE_NODE_LABEL = {
|
||||||
|
...,
|
||||||
|
[NODE_IDS.AI_NEWS]: { icon: '🤖', title: 'AI 뉴스' },
|
||||||
|
};
|
||||||
|
export const INITIAL_NODE_POSITIONS = {
|
||||||
|
...,
|
||||||
|
// 기존 7개 score y: 0,90,180,270,360,450,540 → 8개 y: 0,90,...,630
|
||||||
|
[NODE_IDS.AI_NEWS]: { x: 480, y: 630 },
|
||||||
|
};
|
||||||
|
const SCORE_KEYS = [..., 'AI_NEWS']; // 한 줄 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
폼 모드 `NodePanel` 은 백엔드 `/api/stock/screener/nodes` 응답 기반이라 백엔드 등록만으로 자동 반영.
|
||||||
|
|
||||||
|
테스트 갱신:
|
||||||
|
- `canvasLayout.test.js`: 8 score 노드, 18 엣지 (1+8+8+1), Object.keys(SCORE_NODE_NAME_MAP) === 8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 테스트 전략
|
||||||
|
|
||||||
|
### 16.1 backend 단위 테스트
|
||||||
|
|
||||||
|
| 파일 | 검증 |
|
||||||
|
|------|------|
|
||||||
|
| `test_ai_news_scraper.py` | 네이버 HTML mock 파싱 (3종목 fixture, 빈 HTML, 404 응답) |
|
||||||
|
| `test_ai_news_analyzer.py` | anthropic mock — success / JSON 파싱 실패 / score 범위 클램프 |
|
||||||
|
| `test_ai_news_pipeline.py` | 5종목 mini integration (scraper/analyzer monkeypatch) — top_pos/top_neg 정렬 검증, failures 격리 검증 |
|
||||||
|
| `test_ai_news_node.py` | AiNewsSentiment.compute() — percentile_rank 결과, min_news_count 필터, 빈 컨텍스트 |
|
||||||
|
| `test_screener_schema.py` | news_sentiment DDL 생성 확인 (기존 테스트 보강) |
|
||||||
|
| `test_screener_router.py` | POST /snapshot/refresh-news-sentiment 라우팅 검증 (mock pipeline) |
|
||||||
|
|
||||||
|
### 16.2 frontend 회귀 테스트
|
||||||
|
|
||||||
|
| 파일 | 검증 |
|
||||||
|
|------|------|
|
||||||
|
| `canvasLayout.test.js` (수정) | SCORE_NODE_NAME_MAP 8 entries, EDGES 18, AI_NEWS가 gate→score→combine 경로 가짐 |
|
||||||
|
|
||||||
|
### 16.3 수동 검증 체크리스트
|
||||||
|
|
||||||
|
배포 전 NAS에서:
|
||||||
|
- [ ] 08:00 cron 트리거 (수동 `agent-office.on_ai_news_schedule()`)
|
||||||
|
- [ ] news_sentiment 테이블에 100종목 행 생성 확인
|
||||||
|
- [ ] 텔레그램 메시지 호재/악재 Top 5 + 비용 라인 정상 표시
|
||||||
|
- [ ] 16:30 스크리너 잡이 ai_news 점수 가중합에 반영 (스크리너 결과의 scores.ai_news 컬럼 확인)
|
||||||
|
- [ ] 캔버스 모드에 🤖 AI 뉴스 노드 표시, 활성/비활성 토글 동작
|
||||||
|
- [ ] LLM 실패 시뮬레이션 (ANTHROPIC_API_KEY 잘못 설정 후 cron) → fail-soft 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 배포
|
||||||
|
|
||||||
|
- **백엔드**: stock-lab + agent-office 동시 변경 → git push → Gitea webhook → 자동 deployer rsync + docker compose build
|
||||||
|
- **DB 마이그레이션**: `ensure_screener_schema(conn)` 의 `CREATE TABLE IF NOT EXISTS` 로 자동 (기존 패턴)
|
||||||
|
- **환경변수**: stock-lab docker-compose.yml 에 `AI_NEWS_MODEL` (옵션) 추가 가능. 기본값 `claude-haiku-4-5-20251001`
|
||||||
|
- **프론트**: web-ui에서 `npm run release:nas` (캔버스 노드 1개 추가는 작은 변경)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 후속 슬라이스 후보 (이번 슬라이스 NOT)
|
||||||
|
|
||||||
|
본 슬라이스 완료 후 자연스럽게 이어질 작업:
|
||||||
|
|
||||||
|
1. **URL 단위 캐싱** — 뉴스 분석 비용 ~70% 절감
|
||||||
|
2. **장중 16:00 추가 sentiment cron** — 16:30 스크리너에 더 신선한 데이터 공급
|
||||||
|
3. **종목별 sentiment 트렌드 차트** — 데이터 1-2주 누적 후 시각화
|
||||||
|
4. **시총 200~500 확장** — 중소형주 sentiment 커버리지
|
||||||
|
5. **백테스트** — sentiment 점수가 실수익에 미친 영향 회귀
|
||||||
|
6. **다국어/거시 뉴스 통합** — 글로벌 시장 영향 변수 추가
|
||||||
|
7. **알림 토글** — 운영 중 텔레그램 알림 일시 정지 옵션
|
||||||
|
8. **종목별 sentiment 페이지** — 상세 뉴스 + 점수 + LLM 근거 가시화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. 리스크와 완화
|
||||||
|
|
||||||
|
| 리스크 | 완화 |
|
||||||
|
|--------|------|
|
||||||
|
| 네이버 finance HTML 구조 변경 | 단위 테스트로 빠른 감지. fail-soft (해당 종목 skip). 운영 알림 (실패율 > 30%) |
|
||||||
|
| LLM 응답이 JSON 깨짐 | 종목당 1콜 + JSON-mode prompt + 파싱 실패 시 단일 종목만 skip. lotto curator에서 검증된 패턴 |
|
||||||
|
| 네이버 차단 (429) | Semaphore(10) + 0.2초 sleep + httpx User-Agent. 향후 429 응답 시 exponential backoff 추가 |
|
||||||
|
| anthropic API 비용 폭증 | 일 1회 100종목 = $0.075 상한. 토큰 모니터링 컬럼 + 텔레그램 표시로 즉시 감지 |
|
||||||
|
| 08:00 cron이 16:30 잡과 lock 충돌 | _conn() WAL + busy_timeout=120s 로 흡수. 두 cron 시간 8.5시간 차이로 실질 충돌 없음 |
|
||||||
|
| 16:30 시점 news_sentiment 비어 있음 (cron 실패) | AiNewsSentiment.compute() 가 빈 Series → 가중합에서 자동 제외. 다른 7노드 점수만 사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. 완료 조건 (Definition of Done)
|
||||||
|
|
||||||
|
- [ ] 평일 08:00 KST agent-office cron 등록, 수동 트리거로 실행 검증
|
||||||
|
- [ ] news_sentiment 테이블에 100종목 데이터 일별 생성
|
||||||
|
- [ ] 텔레그램에 호재/악재 Top 5 + 비용 라인 표시
|
||||||
|
- [ ] 16:30 스크리너 잡에서 ai_news 점수가 가중합에 반영 (scores.ai_news 노출)
|
||||||
|
- [ ] 캔버스 모드에 8번째 노드 🤖 AI 뉴스 표시, 가중치/활성/파라미터 편집 동작
|
||||||
|
- [ ] 폼 모드 NodePanel에 AI 뉴스 자동 노출 (백엔드 메타 기반)
|
||||||
|
- [ ] 16.1 단위 테스트 모두 통과
|
||||||
|
- [ ] 16.3 수동 검증 체크리스트 모두 통과
|
||||||
|
- [ ] LLM 실패 시 fail-soft 동작 (전체 잡은 성공으로 끝나고, 실패 종목만 누락)
|
||||||
Reference in New Issue
Block a user