Compare commits
7 Commits
ec3ca5fcfa
...
3bf7ce446f
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf7ce446f | |||
| 8391919b90 | |||
| ed7e927dc1 | |||
| 309bedadeb | |||
| ebdfcd758b | |||
| cefaeca449 | |||
| cdfa31b0c1 |
@@ -65,7 +65,7 @@ proxy: {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/api/*` → NAS 백엔드 (nginx가 서비스별 라우팅: lotto, personal, stock-lab, music-lab 등)
|
- `/api/*` → NAS 백엔드 (nginx가 서비스별 라우팅: lotto, personal, stock, music-lab 등)
|
||||||
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
||||||
- 개발 서버 포트: **3007**
|
- 개발 서버 포트: **3007**
|
||||||
|
|
||||||
|
|||||||
999
docs/superpowers/plans/2026-05-14-ai-news-articles-source.md
Normal file
999
docs/superpowers/plans/2026-05-14-ai-news-articles-source.md
Normal file
@@ -0,0 +1,999 @@
|
|||||||
|
# AI News Phase 1 — articles Source Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** ai_news 파이프라인의 데이터 소스를 Naver 스크래퍼에서 기존 `articles` 테이블로 교체. 종목명 substring 매핑으로 시총 상위 100 ticker 의 뉴스 sentiment 산출. `news_sentiment.source` 컬럼 추가로 Phase 2 비교 baseline 확보.
|
||||||
|
|
||||||
|
**Architecture:** 신규 `articles_source.py` 모듈이 `articles` 테이블 + `krx_master.name` substring 매핑으로 ticker별 뉴스 dict 반환. `pipeline.py`는 scraper 호출 대신 articles_source 사용. `analyzer.py` 가 LLM prompt 에 `summary` 포함. 텔레그램 메시지에 매핑 hit-rate 라인 추가. legacy `scraper.py` 는 deprecate 주석만 추가하고 보존.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11 / SQLite (WAL + busy_timeout) / anthropic AsyncClient / FastAPI / pytest + pytest-asyncio.
|
||||||
|
|
||||||
|
**선행 spec**: `web-ui/docs/superpowers/specs/2026-05-14-ai-news-articles-source-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
신규 파일 (backend):
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/ai_news/
|
||||||
|
articles_source.py ← DB articles 조회 + 종목명 매핑
|
||||||
|
|
||||||
|
web-backend/stock-lab/tests/
|
||||||
|
test_ai_news_articles_source.py ← 6 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 파일 (backend):
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/
|
||||||
|
schema.py ← news_sentiment.source 컬럼 + migration
|
||||||
|
ai_news/pipeline.py ← articles_source 사용, _make_http 제거
|
||||||
|
ai_news/analyzer.py ← prompt에 summary/pub_date 포함
|
||||||
|
ai_news/telegram.py ← build_message 에 mapping 라인
|
||||||
|
ai_news/scraper.py ← deprecate 주석만 추가
|
||||||
|
router.py ← post_refresh_news_sentiment 에 mapping 전달
|
||||||
|
|
||||||
|
web-backend/stock-lab/tests/
|
||||||
|
test_ai_news_pipeline.py ← articles_source mock 으로 갱신
|
||||||
|
test_ai_news_analyzer.py ← summary 케이스 추가
|
||||||
|
test_ai_news_telegram.py ← mapping 인자 케이스 추가
|
||||||
|
test_ai_news_router.py ← mapping 응답 필드 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: schema.py — `news_sentiment.source` 컬럼 + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/schema.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: DDL 본문에 `source` 컬럼 정의 추가**
|
||||||
|
|
||||||
|
`schema.py` 의 `DDL` 문자열 안 `news_sentiment` 테이블 정의에 `source` 컬럼을 `model` 컬럼 다음에 추가:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS news_sentiment (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
score_raw REAL NOT NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
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',
|
||||||
|
source TEXT NOT NULL DEFAULT 'articles',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
PRIMARY KEY (ticker, date)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `ensure_screener_schema()` 함수에 1회성 migration 블록 추가**
|
||||||
|
|
||||||
|
기존 ai_news weight migration 블록 (라인 ~142-156 근처) 직전 또는 직후에 다음을 추가:
|
||||||
|
```python
|
||||||
|
# news_sentiment.source 컬럼 1회 추가 (기존 운영 환경)
|
||||||
|
cols = {r[1] for r in conn.execute(
|
||||||
|
"PRAGMA table_info(news_sentiment)"
|
||||||
|
).fetchall()}
|
||||||
|
if "source" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE news_sentiment "
|
||||||
|
"ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
위치는 `executescript(DDL)` 직후, 기존 ai_news weight migration block 안이 자연스러움.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 기존 schema 테스트 회귀**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
|
||||||
|
python -m pytest app/test_screener_schema.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 3 tests passed (migration 추가에도 idempotency 유지).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/schema.py
|
||||||
|
git commit -m "feat(ai_news): add news_sentiment.source column with migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `articles_source.py` — DB 매핑 모듈 + 6 tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web-backend/stock-lab/app/screener/ai_news/articles_source.py`
|
||||||
|
- Test: `web-backend/stock-lab/tests/test_ai_news_articles_source.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
`tests/test_ai_news_articles_source.py`:
|
||||||
|
```python
|
||||||
|
import datetime as dt
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.screener.ai_news import articles_source
|
||||||
|
from app.screener.schema import ensure_screener_schema
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn():
|
||||||
|
c = sqlite3.connect(":memory:")
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
ensure_screener_schema(c)
|
||||||
|
# krx_master + articles 시드 helper 는 각 테스트에서 진행
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_master(conn, ticker, name):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
|
||||||
|
"VALUES (?, ?, 'KOSPI', 1_000_000_000, datetime('now'))",
|
||||||
|
(ticker, name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_article(conn, title, summary="", crawled_at="2026-05-14T07:30:00"):
|
||||||
|
import hashlib
|
||||||
|
h = hashlib.md5(f"{title}|x".encode()).hexdigest()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO articles (hash, title, summary, link, press, pub_date, crawled_at) "
|
||||||
|
"VALUES (?, ?, ?, '', '', '2026-05-14', ?)",
|
||||||
|
(h, title, summary, crawled_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ASOF = dt.date(2026, 5, 14)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_ticker_match_in_title(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "삼성전자, HBM 양산 가시화")
|
||||||
|
conn.commit()
|
||||||
|
out, stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
assert out["005930"][0]["title"] == "삼성전자, HBM 양산 가시화"
|
||||||
|
assert stats["matched_pairs"] == 1
|
||||||
|
assert stats["hit_tickers"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_ticker_match_in_summary(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "메모리 시장 회복세", summary="삼성전자가 1분기 어닝 서프라이즈")
|
||||||
|
conn.commit()
|
||||||
|
out, _ = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_ticker_match(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_master(conn, "000660", "SK하이닉스")
|
||||||
|
_seed_article(conn, "삼성전자와 SK하이닉스, 메모리 양산 경쟁")
|
||||||
|
conn.commit()
|
||||||
|
out, stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930", "000660"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
assert len(out["000660"]) == 1
|
||||||
|
assert stats["matched_pairs"] == 2
|
||||||
|
assert stats["hit_tickers"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_match_returns_empty_list(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "엔비디아 실적 발표", summary="AI 칩 수요 견조")
|
||||||
|
conn.commit()
|
||||||
|
out, stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert out["005930"] == []
|
||||||
|
assert stats["matched_pairs"] == 0
|
||||||
|
assert stats["hit_tickers"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_per_ticker_caps_results(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
for i in range(6):
|
||||||
|
_seed_article(conn, f"삼성전자 뉴스 #{i}", crawled_at=f"2026-05-14T0{i}:00:00")
|
||||||
|
conn.commit()
|
||||||
|
out, _ = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_days_filters_old_articles(conn):
|
||||||
|
_seed_master(conn, "005930", "삼성전자")
|
||||||
|
_seed_article(conn, "삼성전자 최신 뉴스", crawled_at="2026-05-14T07:00:00")
|
||||||
|
_seed_article(conn, "삼성전자 오래된 뉴스", crawled_at="2026-05-01T07:00:00")
|
||||||
|
conn.commit()
|
||||||
|
out, _ = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
|
||||||
|
)
|
||||||
|
assert len(out["005930"]) == 1
|
||||||
|
assert "최신" in out["005930"][0]["title"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_articles_source.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — "No module named 'app.screener.ai_news.articles_source'".
|
||||||
|
|
||||||
|
- [ ] **Step 3: `articles_source.py` 구현** — 정확히:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_articles_for_tickers(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tickers: List[str],
|
||||||
|
asof: dt.date,
|
||||||
|
*,
|
||||||
|
window_days: int = 1,
|
||||||
|
max_per_ticker: int = 5,
|
||||||
|
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
|
||||||
|
"""articles 에서 ticker.name substring 매칭으로 종목별 뉴스 dict 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(
|
||||||
|
{ticker: [{"title": str, "summary": str, "press": str, "pub_date": str}, ...]},
|
||||||
|
{"total_articles": int, "matched_pairs": int, "hit_tickers": int},
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
|
||||||
|
stats = {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
|
||||||
|
|
||||||
|
if not tickers:
|
||||||
|
return out, stats
|
||||||
|
|
||||||
|
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
|
||||||
|
|
||||||
|
placeholders = ",".join("?" * len(tickers))
|
||||||
|
name_rows = conn.execute(
|
||||||
|
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
|
||||||
|
tickers,
|
||||||
|
).fetchall()
|
||||||
|
# 2글자 미만 회사명은 false positive 위험으로 제외
|
||||||
|
name_map = {r[0]: r[1] for r in name_rows if r[1] and len(r[1]) >= 2}
|
||||||
|
|
||||||
|
articles = conn.execute(
|
||||||
|
"SELECT title, summary, press, pub_date, crawled_at "
|
||||||
|
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
stats["total_articles"] = len(articles)
|
||||||
|
|
||||||
|
for a in articles:
|
||||||
|
title = (a[0] or "").strip()
|
||||||
|
summary = (a[1] or "").strip()
|
||||||
|
haystack = title + " " + summary
|
||||||
|
for ticker, name in name_map.items():
|
||||||
|
if name not in haystack:
|
||||||
|
continue
|
||||||
|
if len(out[ticker]) >= max_per_ticker:
|
||||||
|
continue
|
||||||
|
out[ticker].append({
|
||||||
|
"title": title,
|
||||||
|
"summary": summary,
|
||||||
|
"press": a[2] or "",
|
||||||
|
"pub_date": a[3] or "",
|
||||||
|
})
|
||||||
|
stats["matched_pairs"] += 1
|
||||||
|
|
||||||
|
stats["hit_tickers"] = sum(1 for arts in out.values() if arts)
|
||||||
|
return out, stats
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_articles_source.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 6 tests passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/articles_source.py tests/test_ai_news_articles_source.py
|
||||||
|
git commit -m "feat(ai_news): articles_source module (substring ticker matching)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `analyzer.py` — prompt 에 summary/pub_date 포함
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/analyzer.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_analyzer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
|
||||||
|
|
||||||
|
`tests/test_ai_news_analyzer.py` 의 `NEWS` 상수와 `test_score_sentiment_success_parses_json` 테스트를 다음으로 교체/보강:
|
||||||
|
```python
|
||||||
|
NEWS = [
|
||||||
|
{"title": "삼성전자, HBM 양산", "summary": "1분기 영업이익 사상 최대", "pub_date": "2026-05-14"},
|
||||||
|
{"title": "메모리 가격 반등", "summary": "", "pub_date": "2026-05-14"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_score_sentiment_includes_summary_in_prompt():
|
||||||
|
"""summary 가 있으면 prompt 에 포함, 없으면 title 만."""
|
||||||
|
llm = _mk_llm(json.dumps({"score": 5.0, "reason": "ok"}))
|
||||||
|
await analyzer.score_sentiment(llm, "005930", NEWS, name="삼성전자")
|
||||||
|
# mock 의 messages.create 호출 인자 확인
|
||||||
|
call = llm.messages.create.call_args
|
||||||
|
user_msg = call.kwargs["messages"][0]["content"]
|
||||||
|
assert "1분기 영업이익 사상 최대" in user_msg # summary 포함
|
||||||
|
assert "삼성전자, HBM 양산" in user_msg # title 포함
|
||||||
|
assert "2026-05-14" in user_msg # pub_date 포함
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실행으로 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_analyzer.py::test_score_sentiment_includes_summary_in_prompt -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — `1분기 영업이익 사상 최대` 가 prompt 에 없음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `analyzer.py` 의 news_block 빌더 분리 + summary 포함**
|
||||||
|
|
||||||
|
기존 prompt 빌드 부분 수정. `score_sentiment` 함수의 prompt build 직전에 helper 함수 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 `score_sentiment` 안 `news_block` 계산 라인을 다음으로 교체:
|
||||||
|
```python
|
||||||
|
news_block = _format_news_block(news)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_analyzer.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 5 tests (기존 4 + 신규 1) 모두 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/analyzer.py tests/test_ai_news_analyzer.py
|
||||||
|
git commit -m "feat(ai_news): include summary + pub_date in LLM prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `pipeline.py` — articles_source 사용으로 교체
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/pipeline.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_pipeline.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
|
||||||
|
|
||||||
|
`tests/test_ai_news_pipeline.py` 의 `test_refresh_daily_happy_path` 를 다음으로 교체:
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_happy_path(conn):
|
||||||
|
"""3종목 mini integration — articles_source mock + analyzer mock.
|
||||||
|
|
||||||
|
각 종목에 매핑되는 articles 1개씩 있다고 가정.
|
||||||
|
"""
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "삼성 뉴스", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [{"title": "SK 뉴스", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"373220": [{"title": "LG 뉴스", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
|
|
||||||
|
scores_by_ticker = {
|
||||||
|
"005930": 7.5, "000660": 4.0, "373220": -6.0,
|
||||||
|
}
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": scores_by_ticker[ticker],
|
||||||
|
"reason": f"r{ticker}", "news_count": 1,
|
||||||
|
"tokens_input": 100, "tokens_output": 20, "model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(
|
||||||
|
return_value=(fake_articles_by_ticker, fake_stats)
|
||||||
|
)
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
assert result["asof"] == "2026-05-13"
|
||||||
|
assert result["updated"] == 3
|
||||||
|
assert result["failures"] == []
|
||||||
|
assert result["top_pos"][0]["ticker"] == "005930"
|
||||||
|
assert result["top_neg"][0]["ticker"] == "373220"
|
||||||
|
assert result["mapping"] == fake_stats
|
||||||
|
|
||||||
|
rows = conn.execute("SELECT ticker, score_raw, source FROM news_sentiment "
|
||||||
|
"WHERE date=?", ("2026-05-13",)).fetchall()
|
||||||
|
assert len(rows) == 3
|
||||||
|
assert all(r["source"] == "articles" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_no_match_ticker_skipped(conn):
|
||||||
|
"""매핑 0인 ticker 는 LLM 호출 skip + news_sentiment 행 미생성."""
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "삼성", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [], # 매핑 없음
|
||||||
|
"373220": [], # 매핑 없음
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 1, "matched_pairs": 1, "hit_tickers": 1}
|
||||||
|
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": 5.0, "reason": "r",
|
||||||
|
"news_count": 1, "tokens_input": 100, "tokens_output": 20,
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(
|
||||||
|
return_value=(fake_articles_by_ticker, fake_stats)
|
||||||
|
)
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
assert result["updated"] == 1
|
||||||
|
rows = conn.execute("SELECT ticker FROM news_sentiment "
|
||||||
|
"WHERE date=?", ("2026-05-13",)).fetchall()
|
||||||
|
assert {r["ticker"] for r in rows} == {"005930"}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `test_refresh_daily_failures_isolated` 는 articles_source 매핑 데이터를 추가해야 함:
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_failures_isolated(conn):
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"373220": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
|
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
if ticker == "000660":
|
||||||
|
raise RuntimeError("llm exploded")
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": 5.0, "reason": "r", "news_count": 1,
|
||||||
|
"tokens_input": 100, "tokens_output": 20, "model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(
|
||||||
|
return_value=(fake_articles_by_ticker, fake_stats)
|
||||||
|
)
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
assert result["updated"] == 2
|
||||||
|
assert len(result["failures"]) == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
상단 import 에 `MagicMock` 추가 확인:
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_pipeline.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — pipeline 이 articles_source 를 아직 사용 안 함.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `pipeline.py` 본문 교체**
|
||||||
|
|
||||||
|
`pipeline.py` 의 다음을 변경:
|
||||||
|
|
||||||
|
(1) 상단 import 에 articles_source 추가:
|
||||||
|
```python
|
||||||
|
from . import scraper as _scraper # legacy, kept for backward import
|
||||||
|
from . import analyzer as _analyzer
|
||||||
|
from . import articles_source # 신규
|
||||||
|
```
|
||||||
|
|
||||||
|
(2) `_make_http()` 함수와 `DEFAULT_RATE_LIMIT_SEC` 상수는 제거 (또는 deprecate). 더 이상 사용 안 함.
|
||||||
|
|
||||||
|
(3) `_process_one()` 함수를 다음으로 교체:
|
||||||
|
```python
|
||||||
|
async def _process_one(
|
||||||
|
ticker: str, name: str, articles: List[Dict[str, Any]],
|
||||||
|
sem: asyncio.Semaphore, llm, model: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
return await _analyzer.score_sentiment(
|
||||||
|
llm, ticker, articles, name=name, model=model,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
(4) `refresh_daily()` 시그니처 + 본문 교체:
|
||||||
|
```python
|
||||||
|
async def refresh_daily(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
asof: dt.date,
|
||||||
|
*,
|
||||||
|
top_n: int = DEFAULT_TOP_N,
|
||||||
|
concurrency: int = DEFAULT_CONCURRENCY,
|
||||||
|
max_news_per_ticker: int = DEFAULT_NEWS_PER_TICKER,
|
||||||
|
window_days: int = 1,
|
||||||
|
model: str = _analyzer.DEFAULT_MODEL,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
started = time.time()
|
||||||
|
tickers = _top_market_cap_tickers(conn, n=top_n)
|
||||||
|
name_map = {
|
||||||
|
r[0]: r[1] for r in conn.execute(
|
||||||
|
f"SELECT ticker, name FROM krx_master WHERE ticker IN "
|
||||||
|
f"({','.join('?' * len(tickers))})", tickers,
|
||||||
|
).fetchall()
|
||||||
|
} if tickers else {}
|
||||||
|
|
||||||
|
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, tickers, asof,
|
||||||
|
window_days=window_days,
|
||||||
|
max_per_ticker=max_news_per_ticker,
|
||||||
|
)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
async with _make_llm() as llm:
|
||||||
|
tasks = []
|
||||||
|
for t in tickers:
|
||||||
|
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))
|
||||||
|
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
successes: List[Dict[str, Any]] = []
|
||||||
|
failures: List[str] = []
|
||||||
|
for r in raw_results:
|
||||||
|
if isinstance(r, BaseException):
|
||||||
|
failures.append(repr(r))
|
||||||
|
elif isinstance(r, dict):
|
||||||
|
successes.append(r)
|
||||||
|
|
||||||
|
if successes:
|
||||||
|
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
||||||
|
|
||||||
|
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": failures,
|
||||||
|
"duration_sec": round(time.time() - started, 2),
|
||||||
|
"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,
|
||||||
|
"model": model,
|
||||||
|
"mapping": mapping_stats,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(5) `_upsert_news_sentiment()` 함수에 `source` 인자 추가 + INSERT 에 컬럼 포함:
|
||||||
|
```python
|
||||||
|
def _upsert_news_sentiment(
|
||||||
|
conn: sqlite3.Connection, asof: dt.date,
|
||||||
|
rows: List[Dict[str, Any]], *, source: str = "articles",
|
||||||
|
) -> None:
|
||||||
|
iso = asof.isoformat()
|
||||||
|
data = [
|
||||||
|
(
|
||||||
|
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
|
||||||
|
r["tokens_input"], r["tokens_output"], r["model"], source,
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
conn.executemany(
|
||||||
|
"""INSERT INTO news_sentiment
|
||||||
|
(ticker, date, score_raw, reason, news_count,
|
||||||
|
tokens_input, tokens_output, model, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ticker, date) DO UPDATE SET
|
||||||
|
score_raw=excluded.score_raw,
|
||||||
|
reason=excluded.reason,
|
||||||
|
news_count=excluded.news_count,
|
||||||
|
tokens_input=excluded.tokens_input,
|
||||||
|
tokens_output=excluded.tokens_output,
|
||||||
|
model=excluded.model,
|
||||||
|
source=excluded.source
|
||||||
|
""",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_pipeline.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — `test_refresh_daily_happy_path`, `test_refresh_daily_failures_isolated`, `test_refresh_daily_no_match_ticker_skipped`, `test_top_market_cap_tickers` 모두 통과 (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/pipeline.py tests/test_ai_news_pipeline.py
|
||||||
|
git commit -m "feat(ai_news): pipeline uses articles_source (replaces Naver scraper)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `telegram.py` — 매핑 라인 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/telegram.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_telegram.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
|
||||||
|
|
||||||
|
`tests/test_ai_news_telegram.py` 끝에 새 테스트 추가:
|
||||||
|
```python
|
||||||
|
def test_build_message_includes_mapping_line():
|
||||||
|
msg = tg.build_message(
|
||||||
|
asof="2026-05-14",
|
||||||
|
top_pos=[_row("005930", 8.5, "HBM 호재")],
|
||||||
|
top_neg=[],
|
||||||
|
tokens_input=1000, tokens_output=200,
|
||||||
|
mapping={"total_articles": 35, "matched_pairs": 50, "hit_tickers": 42},
|
||||||
|
)
|
||||||
|
assert "매핑" in msg
|
||||||
|
assert "42" in msg
|
||||||
|
assert "50" in msg
|
||||||
|
assert "35" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_message_without_mapping_omits_line():
|
||||||
|
msg = tg.build_message(
|
||||||
|
asof="2026-05-14",
|
||||||
|
top_pos=[],
|
||||||
|
top_neg=[],
|
||||||
|
tokens_input=1000, tokens_output=200,
|
||||||
|
)
|
||||||
|
assert "매핑" not in msg
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_telegram.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — `mapping` 인자 미지원.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `telegram.py` 의 `build_message` 시그니처 + footer 갱신**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_message(
|
||||||
|
*,
|
||||||
|
asof: str,
|
||||||
|
top_pos: List[Dict[str, Any]],
|
||||||
|
top_neg: List[Dict[str, Any]],
|
||||||
|
tokens_input: int,
|
||||||
|
tokens_output: int,
|
||||||
|
mapping: Dict[str, int] | None = None,
|
||||||
|
) -> str:
|
||||||
|
lines: List[str] = [
|
||||||
|
f"🌅 *AI 뉴스 분석* \\({_escape(asof)} 08:00\\)",
|
||||||
|
"",
|
||||||
|
"📈 *호재 Top 5*",
|
||||||
|
]
|
||||||
|
if top_pos:
|
||||||
|
for i, r in enumerate(top_pos, 1):
|
||||||
|
lines.append(_row_line(i, r))
|
||||||
|
else:
|
||||||
|
lines.append(_escape("- (없음)"))
|
||||||
|
|
||||||
|
lines += ["", "📉 *악재 Top 5*"]
|
||||||
|
if top_neg:
|
||||||
|
for i, r in enumerate(top_neg, 1):
|
||||||
|
lines.append(_row_line(i, r))
|
||||||
|
else:
|
||||||
|
lines.append(_escape("- (없음)"))
|
||||||
|
|
||||||
|
cost = _cost_won(tokens_input, tokens_output)
|
||||||
|
mapping_part = ""
|
||||||
|
if mapping:
|
||||||
|
mapping_part = (
|
||||||
|
f"매핑 {mapping['hit_tickers']}/100 ticker "
|
||||||
|
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}건\\) · "
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_분석: 시총 상위 100종목 · {mapping_part}"
|
||||||
|
f"토큰 {tokens_input:,} in / {tokens_output:,} out · "
|
||||||
|
f"약 ₩{cost:,}_",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_telegram.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 6 tests (기존 4 + 신규 2) 모두 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/telegram.py tests/test_ai_news_telegram.py
|
||||||
|
git commit -m "feat(ai_news): telegram includes article mapping stats line"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: `router.py` — mapping 응답 필드 전달
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/router.py`
|
||||||
|
- Modify: `web-backend/stock-lab/tests/test_ai_news_router.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트 갱신**
|
||||||
|
|
||||||
|
`tests/test_ai_news_router.py` 의 `test_refresh_news_sentiment_weekday_invokes_pipeline` 보강:
|
||||||
|
```python
|
||||||
|
def test_refresh_news_sentiment_weekday_invokes_pipeline():
|
||||||
|
fake_summary = {
|
||||||
|
"asof": "2026-05-13", "updated": 3, "failures": [],
|
||||||
|
"duration_sec": 1.0, "tokens_input": 100, "tokens_output": 20,
|
||||||
|
"top_pos": [], "top_neg": [], "model": "m",
|
||||||
|
"mapping": {"total_articles": 5, "matched_pairs": 8, "hit_tickers": 3},
|
||||||
|
}
|
||||||
|
with patch("app.screener.router._ai_pipeline") as mp, \
|
||||||
|
patch("app.screener.router._ai_telegram") as mt:
|
||||||
|
mp.refresh_daily = AsyncMock(return_value=fake_summary)
|
||||||
|
mt.build_message = lambda **kw: f"TEXT_with_mapping={kw.get('mapping')}"
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/stock/screener/snapshot/refresh-news-sentiment?asof=2026-05-13"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["mapping"]["hit_tickers"] == 3
|
||||||
|
assert "mapping=" in body["telegram_text"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_router.py -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — `mapping` 이 build_message 호출에 전달되지 않음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `router.py` 의 `post_refresh_news_sentiment` 의 telegram_text 빌드 갱신**
|
||||||
|
|
||||||
|
기존:
|
||||||
|
```python
|
||||||
|
summary["telegram_text"] = _ai_telegram.build_message(
|
||||||
|
asof=summary["asof"],
|
||||||
|
top_pos=summary["top_pos"], top_neg=summary["top_neg"],
|
||||||
|
tokens_input=summary["tokens_input"],
|
||||||
|
tokens_output=summary["tokens_output"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
다음으로 교체:
|
||||||
|
```python
|
||||||
|
summary["telegram_text"] = _ai_telegram.build_message(
|
||||||
|
asof=summary["asof"],
|
||||||
|
top_pos=summary["top_pos"], top_neg=summary["top_neg"],
|
||||||
|
tokens_input=summary["tokens_input"],
|
||||||
|
tokens_output=summary["tokens_output"],
|
||||||
|
mapping=summary.get("mapping"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_ai_news_router.py -v
|
||||||
|
```
|
||||||
|
Expected: PASS — 2 tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/router.py tests/test_ai_news_router.py
|
||||||
|
git commit -m "feat(ai_news): router forwards mapping stats to telegram"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 전체 회귀 + scraper deprecate 주석
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock-lab/app/screener/ai_news/scraper.py` (주석만)
|
||||||
|
|
||||||
|
- [ ] **Step 1: scraper.py 상단에 deprecate 주석 추가**
|
||||||
|
|
||||||
|
기존 docstring 을 다음으로 교체:
|
||||||
|
```python
|
||||||
|
"""[DEPRECATED] 네이버 finance 종목 뉴스 스크래핑.
|
||||||
|
|
||||||
|
본 모듈은 ai_news Phase 1 (2026-05-14, `cdfa31b` spec) 에서 더 이상
|
||||||
|
파이프라인에서 사용되지 않음. 데이터 소스는 stock-lab 의 articles 테이블
|
||||||
|
(`ai_news/articles_source.py`) 로 전환됨.
|
||||||
|
|
||||||
|
삭제 시점: Phase 2 (DART 도입) 결정 후. IC 검증 4주 누적 후 노드 활성화
|
||||||
|
여부에 따라 본 모듈을 (a) 완전 삭제 또는 (b) DART 와 함께 ensemble
|
||||||
|
fallback 으로 재활용.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
다른 라인은 유지 (테스트가 여전히 import 함).
|
||||||
|
|
||||||
|
- [ ] **Step 2: 전체 stock-lab 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
|
||||||
|
python -m pytest --ignore=app/test_scraper.py -q
|
||||||
|
```
|
||||||
|
Expected: 신규 6 + 갱신 테스트 포함 **82 tests passed** (이전 76 + ai_news_articles_source 6 - 변동 없음).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/screener/ai_news/scraper.py
|
||||||
|
git commit -m "docs(ai_news): mark scraper.py deprecated (Phase 1 transition)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: 운영 검증 + 배포
|
||||||
|
|
||||||
|
**Files:** (실행만, 수동 점검)
|
||||||
|
|
||||||
|
- [ ] **Step 1: backend push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
실패 시: 사용자에게 Gitea 자격증명 입력 요청.
|
||||||
|
|
||||||
|
- [ ] **Step 2: deployer 반영 확인 (~1분)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs stock-lab --tail 20 2>&1 | grep -i "starting\|started"
|
||||||
|
docker logs agent-office --tail 20 2>&1 | grep -i "starting\|started"
|
||||||
|
```
|
||||||
|
두 컨테이너 모두 새 startup 시각 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 운영 DB 마이그레이션 자동 적용 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec stock-lab python -c "
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect('/app/data/stock.db')
|
||||||
|
cols = [r[1] for r in c.execute('PRAGMA table_info(news_sentiment)').fetchall()]
|
||||||
|
print('news_sentiment columns:', cols)
|
||||||
|
print('has source:', 'source' in cols)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `has source: True`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 수동 트리거**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent":"stock","action":"run_ai_news"}'
|
||||||
|
```
|
||||||
|
응답 `{"ok": true}` 받으면 30-60초 후 텔레그램에 메시지 도착.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 텔레그램 메시지 검증**
|
||||||
|
|
||||||
|
수신 메시지에 다음 패턴 모두 포함되는지 확인:
|
||||||
|
- `🌅 AI 뉴스 분석 (YYYY-MM-DD 08:00)` 헤더
|
||||||
|
- `📈 호재 Top 5` / `📉 악재 Top 5` 섹션
|
||||||
|
- 종목명 + 티커 형태 (예: `삼성전자 (005930)`)
|
||||||
|
- `매핑 N/100 ticker (M쌍 / articles K건)` 라인 (신규)
|
||||||
|
- 토큰/비용 라인
|
||||||
|
|
||||||
|
매핑 hit_tickers 가 합리적 범위 (예: 20~60) 인지 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 6: DB 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec stock-lab python -c "
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect('/app/data/stock.db')
|
||||||
|
rows = c.execute('SELECT COUNT(*), SUM(news_count), SUM(tokens_input) FROM news_sentiment WHERE date = date(\"now\") AND source = \"articles\"').fetchone()
|
||||||
|
print('articles rows / total_news / tokens:', rows)
|
||||||
|
# Naver 데이터와 비교
|
||||||
|
naver = c.execute('SELECT COUNT(*) FROM news_sentiment WHERE source = \"articles\"').fetchone()
|
||||||
|
print('all articles-source rows:', naver[0])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: `articles rows >= 10` (매핑 hit 종목 수), `source='articles'`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: 메모리 업데이트**
|
||||||
|
|
||||||
|
`C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-web-ui\memory\project_stock_screener.md` 의 hotfix 이력에 본 슬라이스 commits 추가:
|
||||||
|
- Phase 1 (`cdfa31b` spec + 본 plan 의 task commit SHA들)
|
||||||
|
- 매핑 hit-rate 측정 결과 (예: "첫 실행 매핑 42/100, articles 35건, LLM cost ₩42")
|
||||||
|
- 다음 단계: 4주 후 IC 측정 결과 보고 Phase 2 (DART) 또는 노드 삭제 결정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 검증 체크리스트
|
||||||
|
|
||||||
|
본 plan 완료 시:
|
||||||
|
- [ ] stock-lab `news_sentiment` 테이블에 `source` 컬럼 존재
|
||||||
|
- [ ] 운영 트리거 시 source='articles' 행 생성, news_count > 0
|
||||||
|
- [ ] 텔레그램 메시지에 매핑 N/100 라인 표시
|
||||||
|
- [ ] 외부 HTTP 호출 (Naver) 0건
|
||||||
|
- [ ] LLM cost 텔레그램 ₩ 라인이 이전(~₩60)보다 작거나 비슷 (~₩40-80)
|
||||||
|
- [ ] 단위 테스트 신규 6 + 갱신 4 모두 통과, 기존 회귀 없음
|
||||||
|
- [ ] `news_sentiment.source` 컬럼이 idempotent 하게 추가 (재기동 시 재추가 시도 없음)
|
||||||
|
- [ ] legacy `scraper.py` 에 deprecate 주석 (코드 보존)
|
||||||
|
|
||||||
|
## 후속 슬라이스 (이번 plan 완료 후)
|
||||||
|
|
||||||
|
본 spec §15 명시:
|
||||||
|
- **Phase 1.5** — 매핑 hit-rate < 30% 면 alias dict 추가
|
||||||
|
- **Phase 2** — 4주 IC ≥ 0.05 시 DART OpenAPI 추가
|
||||||
|
- **Phase X** — IC < 0.05 시 노드 deprecate
|
||||||
506
docs/superpowers/plans/2026-05-15-stock-lab-rename-to-stock.md
Normal file
506
docs/superpowers/plans/2026-05-15-stock-lab-rename-to-stock.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
# stock-lab → stock 리네이밍 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** `stock-lab` 컨테이너/디렉토리/환경변수를 `stock` 으로 graduation. lab 네이밍 정책 정리 + V2 Phase 1 작업 시작 전 선행.
|
||||||
|
|
||||||
|
**Architecture:** Atomic refactor — web-backend repo 안의 모든 stock-lab 참조를 한 commit으로 갱신 (git mv + docker-compose + agent-office + nginx + 문서). web-ui/workspace CLAUDE.md 별도 commit. 메모리는 controller 직접 갱신. Python `app.*` import 경로 + API URL `/api/stock/...` + DB 파일 그대로 유지.
|
||||||
|
|
||||||
|
**Tech Stack:** Git (mv with history), Docker Compose, nginx upstream, Python FastAPI / httpx.
|
||||||
|
|
||||||
|
**선행 spec**: `web-ui/docs/superpowers/specs/2026-05-15-stock-lab-rename-to-stock.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사전 가정
|
||||||
|
|
||||||
|
- web-backend repo 와 web-ui repo 는 별도 git 저장소
|
||||||
|
- `workspace/CLAUDE.md` 는 git 관리 외 파일 (단순 편집)
|
||||||
|
- `stock-lab/.venv/` 디렉토리는 `.gitignore` 되어 있음 (Windows 로컬 가상환경, 변경 영향 무관)
|
||||||
|
- Gitea webhook 자동 배포: web-backend push → deployer rsync + docker compose up
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 변경 매트릭스 요약 (Task 별로 상세)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Task 1] grep 사전 검토 (코드 변경 0)
|
||||||
|
|
||||||
|
[Task 2] web-backend atomic commit
|
||||||
|
- git mv stock-lab → stock (수십 파일)
|
||||||
|
- docker-compose.yml (서비스 키 + container_name + build.context + depends_on + agent-office env)
|
||||||
|
- agent-office/app/config.py (STOCK_LAB_URL → STOCK_URL)
|
||||||
|
- agent-office/app/service_proxy.py (import + 5 함수)
|
||||||
|
- agent-office/app/agents/stock.py (있다면)
|
||||||
|
- agent-office/tests/test_stock_screener_job.py
|
||||||
|
- nginx/default.conf (upstream + proxy_pass)
|
||||||
|
- CLAUDE.md, README.md, STATUS.md
|
||||||
|
- scripts/deploy-nas.sh, deploy.sh
|
||||||
|
|
||||||
|
[Task 3] web-ui commit
|
||||||
|
- web-ui/CLAUDE.md
|
||||||
|
|
||||||
|
[Task 4] workspace 편집 (git 없음 가능)
|
||||||
|
- workspace/CLAUDE.md
|
||||||
|
|
||||||
|
[Task 5] 메모리 갱신 (controller, 별도 git 외)
|
||||||
|
- project_workspace.md / project_scale.md / project_stock_screener.md / nas_infra.md
|
||||||
|
- feedback_lab_naming.md (graduation 사례)
|
||||||
|
|
||||||
|
[Task 6] 배포 + 검증
|
||||||
|
- 사용자 push (Gitea 자격증명) + NAS 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 사전 검토 — 모든 stock-lab 참조 위치 확인
|
||||||
|
|
||||||
|
**Files:** (검증만, 변경 없음)
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-backend stock-lab 참조 전체 grep (docs / .venv / __pycache__ 제외)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" . \
|
||||||
|
--exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git --exclude-dir=docs \
|
||||||
|
2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (예상): 다음 파일들이 등장해야 함:
|
||||||
|
- `./agent-office/app/agents/stock.py`
|
||||||
|
- `./agent-office/app/config.py`
|
||||||
|
- `./agent-office/app/service_proxy.py`
|
||||||
|
- `./agent-office/tests/test_stock_screener_job.py`
|
||||||
|
- `./CLAUDE.md`
|
||||||
|
- `./docker-compose.yml`
|
||||||
|
- `./nginx/default.conf`
|
||||||
|
- `./README.md`
|
||||||
|
- `./scripts/deploy-nas.sh`
|
||||||
|
- `./scripts/deploy.sh`
|
||||||
|
- `./STATUS.md`
|
||||||
|
- `./stock-lab/...` (stock-lab 내부 파일들 — `app/main.py`, 테스트 등 내부 참조는 디렉토리 rename 으로 자연 해소)
|
||||||
|
|
||||||
|
- [ ] **Step 2: web-ui stock-lab 참조 grep**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||||||
|
grep -rln "stock-lab" . \
|
||||||
|
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs \
|
||||||
|
2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `./CLAUDE.md` 만.
|
||||||
|
|
||||||
|
- [ ] **Step 3: nginx/default.conf 정확한 변경 라인 식별**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -nE "stock-lab|upstream stock" /c/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `upstream stock-lab { ... }` 블록 정의 + `proxy_pass http://stock-lab` 호출 라인 (1-3 곳).
|
||||||
|
|
||||||
|
- [ ] **Step 4: web-backend stock-lab 내부의 자기 참조 확인 (디렉토리 rename 후 영향)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rln "stock-lab" /c/Users/jaeoh/Desktop/workspace/web-backend/stock-lab/ \
|
||||||
|
--exclude-dir=.venv --exclude-dir=__pycache__ 2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `app/main.py` 의 헬스체크 메시지 + 일부 CLAUDE.md/README.md 문구. Python `app.*` import 는 stock-lab 문자열 없으므로 0건. 발견된 매칭은 Task 2 의 7단계 (디렉토리 내부 문서) 에서 처리.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 사용자에게 `.venv` 삭제 요청 (선택사항이지만 git mv 안전성 향상)**
|
||||||
|
|
||||||
|
사용자에게 다음 메시지:
|
||||||
|
> "git mv stock-lab → stock 직전에 `web-backend/stock-lab/.venv/` 디렉토리 삭제 권장 (Windows local 가상환경, .gitignore 되어있어 영향 없음. 사용 시 재생성 필요). 삭제 완료 후 Task 2 진행."
|
||||||
|
|
||||||
|
Step 5 는 사용자 직접 실행:
|
||||||
|
```bash
|
||||||
|
rm -rf /c/Users/jaeoh/Desktop/workspace/web-backend/stock-lab/.venv
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Step 1-4 결과 기록 (commit 없음, Task 2 의 cross-check 자료)**
|
||||||
|
|
||||||
|
기록할 항목:
|
||||||
|
- 변경 대상 파일 N개 (Step 1 출력)
|
||||||
|
- nginx config 의 정확한 변경 라인 (예: 라인 12, 18, 25 등)
|
||||||
|
- 사용자가 `.venv` 삭제 완료했는지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: web-backend repo atomic commit
|
||||||
|
|
||||||
|
**Files:** (web-backend repo)
|
||||||
|
- Rename: `stock-lab/` → `stock/`
|
||||||
|
- Modify: `docker-compose.yml`
|
||||||
|
- Modify: `agent-office/app/config.py`
|
||||||
|
- Modify: `agent-office/app/service_proxy.py`
|
||||||
|
- Modify: `agent-office/app/agents/stock.py` (해당 시)
|
||||||
|
- Modify: `agent-office/tests/test_stock_screener_job.py`
|
||||||
|
- Modify: `nginx/default.conf`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `STATUS.md`
|
||||||
|
- Modify: `scripts/deploy-nas.sh`
|
||||||
|
- Modify: `scripts/deploy.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: git mv 디렉토리 rename**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git mv stock-lab stock
|
||||||
|
git status --short | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: git status 에 `R stock-lab/... -> stock/...` 라인 다수. .venv 가 사용자에 의해 사전 삭제되었다면 무관, 살아있어도 .gitignore 로 untracked.
|
||||||
|
|
||||||
|
- [ ] **Step 2: docker-compose.yml 갱신**
|
||||||
|
|
||||||
|
`docker-compose.yml` 안 4 곳 변경:
|
||||||
|
1. `services:` 아래 `stock-lab:` 키 → `stock:`
|
||||||
|
2. `container_name: stock-lab` → `container_name: stock`
|
||||||
|
3. `build:` 의 `context: ./stock-lab` → `context: ./stock`
|
||||||
|
4. `frontend:` 의 `depends_on:` 항목 중 `- stock-lab` → `- stock`
|
||||||
|
5. `agent-office:` 의 `environment:` 안 `STOCK_LAB_URL=http://stock-lab:8000` → `STOCK_URL=http://stock:8000`
|
||||||
|
|
||||||
|
수정 명령 (Edit tool 로 안전하게):
|
||||||
|
- `stock-lab:` 단일 occurrence → `stock:`
|
||||||
|
- `container_name: stock-lab` → `container_name: stock`
|
||||||
|
- `context: ./stock-lab` → `context: ./stock`
|
||||||
|
- `- stock-lab` (frontend.depends_on 항목) → `- stock`
|
||||||
|
- `STOCK_LAB_URL=http://stock-lab:8000` → `STOCK_URL=http://stock:8000`
|
||||||
|
|
||||||
|
- [ ] **Step 3: agent-office/app/config.py 갱신**
|
||||||
|
|
||||||
|
`STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://stock-lab:8000")` 형태의 라인을:
|
||||||
|
```python
|
||||||
|
STOCK_URL = os.getenv("STOCK_URL", "http://stock:8000")
|
||||||
|
```
|
||||||
|
으로 교체. 다른 lab URL (MUSIC_LAB_URL 등) 은 그대로 유지.
|
||||||
|
|
||||||
|
- [ ] **Step 4: agent-office/app/service_proxy.py 갱신**
|
||||||
|
|
||||||
|
상단 import:
|
||||||
|
```python
|
||||||
|
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||||
|
```
|
||||||
|
을:
|
||||||
|
```python
|
||||||
|
from .config import STOCK_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
|
||||||
|
```
|
||||||
|
으로 변경.
|
||||||
|
|
||||||
|
함수 본문의 `STOCK_LAB_URL` 사용 5개 (fetch_stock_news / fetch_stock_indices / summarize_stock_news / refresh_screener_snapshot / run_stock_screener) 모두 `STOCK_URL` 로 변경. 또한 본 spec 이후 추가된 `refresh_ai_news_sentiment` 함수도 STOCK_URL 사용.
|
||||||
|
|
||||||
|
가장 단순한 방법: 파일 안 모든 `STOCK_LAB_URL` → `STOCK_URL` 치환 (replace_all).
|
||||||
|
|
||||||
|
- [ ] **Step 5: agent-office/app/agents/stock.py 갱신**
|
||||||
|
|
||||||
|
다음 패턴 grep:
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office/app/agents/stock.py
|
||||||
|
```
|
||||||
|
|
||||||
|
매칭이 있으면 (`stock-lab` 호스트 URL 또는 환경변수명 직접 참조) 갱신. 없으면 skip.
|
||||||
|
|
||||||
|
- [ ] **Step 6: agent-office/tests/test_stock_screener_job.py 갱신**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office/tests/test_stock_screener_job.py
|
||||||
|
```
|
||||||
|
|
||||||
|
mock URL 또는 환경변수 참조 갱신. `STOCK_LAB_URL` → `STOCK_URL`, `http://stock-lab:` → `http://stock:`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: nginx/default.conf 갱신**
|
||||||
|
|
||||||
|
Task 1 Step 3 에서 식별된 라인 모두 변경:
|
||||||
|
- `upstream stock-lab` → `upstream stock`
|
||||||
|
- `server stock-lab:8000;` → `server stock:8000;`
|
||||||
|
- `proxy_pass http://stock-lab` → `proxy_pass http://stock`
|
||||||
|
|
||||||
|
- [ ] **Step 8: 운영 문서 갱신 (CLAUDE.md / README.md / STATUS.md / scripts/)**
|
||||||
|
|
||||||
|
각 파일 grep 후 모든 stock-lab 언급을 stock 으로 교체:
|
||||||
|
- `web-backend/CLAUDE.md` — 디렉토리 표 + 서비스 표 + 환경변수 표
|
||||||
|
- `web-backend/README.md` — 동일
|
||||||
|
- `web-backend/STATUS.md` — 동일
|
||||||
|
- `web-backend/scripts/deploy-nas.sh` — stock-lab 호출/경로 갱신
|
||||||
|
- `web-backend/scripts/deploy.sh` — 동일
|
||||||
|
|
||||||
|
수정 방법: 각 파일에 대해 grep → Edit tool replace_all (단, 의도적 보존 항목 — 예: 과거 변경 이력 등 — 있는지 검토).
|
||||||
|
|
||||||
|
- [ ] **Step 9: stock 디렉토리 내부 문서 갱신**
|
||||||
|
|
||||||
|
Task 1 Step 4 에서 발견된 stock-lab 내부 자기 참조 (예: `stock/CLAUDE.md`, `stock/app/main.py` 헬스체크 문구) 모두 갱신.
|
||||||
|
|
||||||
|
- [ ] **Step 10: agent-office 테스트 회귀 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||||||
|
python -m pytest tests/test_stock_screener_job.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS — `STOCK_LAB_URL` 참조 없이 새 `STOCK_URL` 환경변수 기반으로 mock 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 11: stock pytest 회귀**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/stock
|
||||||
|
python -m pytest --ignore=app/test_scraper.py -q 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 80+ tests passed (이전 76 + Phase 1 작업 전 검증). 디렉토리 이름만 변경, 코드 무변. 회귀 0건.
|
||||||
|
|
||||||
|
- [ ] **Step 12: 최종 grep 검증 — stock-lab 잔여 0건**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" . \
|
||||||
|
--exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git --exclude-dir=docs \
|
||||||
|
2>&1 | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: **0 lines** (의도적 보존된 docs/ 제외).
|
||||||
|
|
||||||
|
만약 0건이 아니면 빠진 위치 찾아서 추가 갱신 후 재검증.
|
||||||
|
|
||||||
|
- [ ] **Step 13: web-backend atomic commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git add -A
|
||||||
|
git status --short | head -20
|
||||||
|
git commit -m "refactor: rename stock-lab → stock (graduation)
|
||||||
|
|
||||||
|
- git mv stock-lab/ → stock/
|
||||||
|
- docker-compose.yml: 서비스 키 + container_name + build.context +
|
||||||
|
frontend.depends_on + agent-office STOCK_LAB_URL → STOCK_URL
|
||||||
|
- agent-office/app: config.py, service_proxy.py STOCK_LAB_URL → STOCK_URL
|
||||||
|
- nginx/default.conf: upstream + proxy_pass 갱신
|
||||||
|
- CLAUDE.md / README.md / STATUS.md / scripts/ 문구 갱신
|
||||||
|
|
||||||
|
lab 네이밍 정책 (feedback_lab_naming.md) 에 따라 정식 graduation.
|
||||||
|
API URL / Python import / DB 파일명은 변경 없음."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: web-ui CLAUDE.md 갱신
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/CLAUDE.md` (web-ui repo)
|
||||||
|
|
||||||
|
- [ ] **Step 1: stock-lab 언급 grep**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab" /c/Users/jaeoh/Desktop/workspace/web-ui/CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 디렉토리 경로 / 라우팅 설명 / API 표 등에서 다수 매칭.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 모두 stock 으로 교체**
|
||||||
|
|
||||||
|
Edit tool 의 `replace_all=true` 로 `stock-lab` → `stock` 일괄 치환. 단, "stock screener" 같은 단어는 영향 없음 (정확한 `stock-lab` 문자열만 매칭).
|
||||||
|
|
||||||
|
- [ ] **Step 3: commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs: rename stock-lab → stock in CLAUDE.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: workspace/CLAUDE.md 갱신 (git 외 가능)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `/c/Users/jaeoh/Desktop/workspace/CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: git 관리 여부 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /c/Users/jaeoh/Desktop/workspace/.git 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `No such file or directory` — workspace 자체는 git repo 아님. 단순 파일 편집.
|
||||||
|
|
||||||
|
(만약 git 관리 중이라면 별도 commit 진행)
|
||||||
|
|
||||||
|
- [ ] **Step 2: stock-lab 언급 grep + 교체**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab" /c/Users/jaeoh/Desktop/workspace/CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit tool 로 `stock-lab` → `stock` 일괄 치환.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 변경 사항 사용자에게 알림 (commit 없음, 단순 파일)**
|
||||||
|
|
||||||
|
workspace/CLAUDE.md 는 단순 파일 — 자동 syncing 없음. 사용자에게 다음 메시지 전달:
|
||||||
|
> "workspace/CLAUDE.md 갱신 완료. git 관리 외 파일이라 commit 없음. 다음 세션부터 자동 적용."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 메모리 갱신 (controller 직접)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-web-ui\memory\project_workspace.md`
|
||||||
|
- Modify: `...\memory\project_scale.md`
|
||||||
|
- Modify: `...\memory\project_stock_screener.md`
|
||||||
|
- Modify: `...\memory\nas_infra.md`
|
||||||
|
- Modify: `...\memory\feedback_lab_naming.md` (graduation 사례 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 메모리 폴더 grep**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/ 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
매칭 파일 모두 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 각 메모리에서 stock-lab → stock 갱신**
|
||||||
|
|
||||||
|
다음 표를 보고 각 파일에서 Edit:
|
||||||
|
|
||||||
|
| 파일 | 주요 갱신 |
|
||||||
|
|------|----------|
|
||||||
|
| `project_workspace.md` | "stock-lab/" → "stock/" (디렉토리 경로) |
|
||||||
|
| `project_scale.md` | 백엔드 서비스 표의 stock-lab 행 → stock |
|
||||||
|
| `project_stock_screener.md` | 백엔드 위치 / 컨테이너 이름 모두 |
|
||||||
|
| `nas_infra.md` | Docker 서비스 포트 표 + nginx 라우팅 |
|
||||||
|
|
||||||
|
- [ ] **Step 3: feedback_lab_naming.md 에 graduation 사례 등재**
|
||||||
|
|
||||||
|
기존 메모리 본문 끝에 다음 추가:
|
||||||
|
```markdown
|
||||||
|
|
||||||
|
## Graduation 이력
|
||||||
|
- **2026-05-15**: `stock-lab` → `stock` graduation. 8 노드 screener + 캔버스 UI + AI 뉴스 Phase 1 + V2 시그널 파이프라인 중심 = 정식 서비스 단계. 디렉토리/컨테이너/환경변수 (`STOCK_LAB_URL` → `STOCK_URL`) 갱신. API URL `/api/stock/*` + Python import / DB 파일명 그대로.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: MEMORY.md 인덱스의 stock_screener 행에 영향 있나 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "stock-lab" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md
|
||||||
|
```
|
||||||
|
|
||||||
|
매칭 있으면 갱신, 없으면 skip.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 메모리 폴더 잔여 grep 검증 (0건)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/ 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 lines (feedback_lab_naming.md의 graduation 본문 안에 의도적으로 "stock-lab" 언급은 가능 — 정책 사례 명시).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 배포 + 운영 검증
|
||||||
|
|
||||||
|
**Files:** (실행만, 변경 없음)
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-backend push (사용자 수동, Gitea 자격증명 필요)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
자격증명 prompt 시 사용자가 입력. push 성공 시 Gitea webhook → deployer rsync + docker compose up 자동.
|
||||||
|
|
||||||
|
- [ ] **Step 2: web-ui push (사용자 수동)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
자격증명 prompt. 본 push 는 CLAUDE.md 한 줄 변경만이라 deployer 영향 없음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: NAS 컨테이너 상태 확인 (사용자가 NAS SSH)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format "{{.Names}}: {{.Status}}" | grep -E "stock|agent-office"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `stock: Up (healthy)` 라인 존재 (옛 stock-lab 컨테이너는 사라짐)
|
||||||
|
- `agent-office: Up (healthy)`
|
||||||
|
|
||||||
|
- [ ] **Step 4: stock 컨테이너 로그 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs stock --tail 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FastAPI startup 로그, init_db 완료, 어떤 stock-lab 잔여 참조나 에러 없음.
|
||||||
|
|
||||||
|
- [ ] **Step 5: agent-office 환경변수 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec agent-office env | grep -E "STOCK|stock"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `STOCK_URL=http://stock:8000` (새 변수)
|
||||||
|
- (옛 `STOCK_LAB_URL` 잔여가 없어야 — `.env` 파일에 남아있으면 사용자가 수동 삭제)
|
||||||
|
|
||||||
|
- [ ] **Step 6: API curl 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://gahusb.synology.me/api/stock/news | python -m json.tool | head -10
|
||||||
|
curl -s https://gahusb.synology.me/api/stock/screener/runs | python -m json.tool | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 200 응답, JSON 파싱 정상.
|
||||||
|
|
||||||
|
- [ ] **Step 7: agent-office 수동 트리거 (테스트)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent":"stock","action":"run_ai_news"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{"ok": true, "message": "AI 뉴스 분석 트리거 완료"}`. 30-60초 후 텔레그램 메시지 도착 = stock 호스트 라우팅 정상.
|
||||||
|
|
||||||
|
- [ ] **Step 8: web-ui 페이지 회귀 (브라우저)**
|
||||||
|
|
||||||
|
`https://gahusb.synology.me/stock/screener` 접속:
|
||||||
|
- 캔버스 모드 진입 정상
|
||||||
|
- 슬라이더 조작 → settings PUT 정상 (X-WebAI-Key 미사용 상태에서도 통과 — 인증은 Phase 1 작업)
|
||||||
|
- 노드 변경 즉시 반영
|
||||||
|
|
||||||
|
`https://gahusb.synology.me/portfolio` 접속:
|
||||||
|
- portfolio 페이지 정상 (current_price/PnL 표시 — Phase 1 작업 전이므로 raw 값만 표시)
|
||||||
|
|
||||||
|
- [ ] **Step 9: 운영 .env 파일 정리 안내 (사용자 수동)**
|
||||||
|
|
||||||
|
NAS의 `/volume1/docker/webpage/.env` 파일에서:
|
||||||
|
- `STOCK_LAB_URL=...` 라인 삭제 (또는 `STOCK_URL=...` 로 갱신)
|
||||||
|
- agent-office 컨테이너 재기동 필요 시: `docker restart agent-office`
|
||||||
|
|
||||||
|
사용자에게 알림:
|
||||||
|
> "NAS의 .env 파일에서 옛 STOCK_LAB_URL 라인 제거 권장. agent-office 의 default fallback (`http://stock:8000`) 으로 동작 가능하지만, 명시적 STOCK_URL 등재가 깔끔."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 검증 체크리스트
|
||||||
|
|
||||||
|
- [ ] `web-backend/stock-lab/` 사라지고 `stock/` 존재 (`ls web-backend/stock` 확인)
|
||||||
|
- [ ] `grep -rln "stock-lab\|STOCK_LAB" web-backend --exclude-dir=docs --exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git` → 0 lines
|
||||||
|
- [ ] web-ui/CLAUDE.md stock-lab 0건
|
||||||
|
- [ ] workspace/CLAUDE.md stock-lab 0건
|
||||||
|
- [ ] 메모리 폴더 stock-lab 0건 (feedback_lab_naming.md graduation 본문 외)
|
||||||
|
- [ ] docker ps 에 `stock` 컨테이너 healthy
|
||||||
|
- [ ] curl `/api/stock/news` 200
|
||||||
|
- [ ] agent-office `run_ai_news` 수동 트리거 + 텔레그램 도착
|
||||||
|
- [ ] stock pytest 76+ tests passed (회귀 0)
|
||||||
|
- [ ] agent-office tests 통과
|
||||||
|
- [ ] web-ui 페이지 (portfolio + screener) 정상
|
||||||
|
|
||||||
|
## 본 plan 완료 후 다음 단계
|
||||||
|
|
||||||
|
- **Confidence Signal Pipeline V2 Phase 1 brainstorming 시작** (이전 발표 디자인 그대로, 새 이름 `stock` 기준)
|
||||||
|
- spec → plan → 실행 (1주 작업 예상)
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# AI News Phase 1 — `articles` Source Integration Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-14
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**: `2026-05-13-ai-news-sentiment-node-design.md`
|
||||||
|
**선행 review**: adversarial review (Claude general-purpose, codex CLI ENOENT fallback)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`ai_news` 파이프라인의 데이터 소스를 **Naver 종목 뉴스 스크래핑 → 기존 `articles` 테이블 재사용** 으로 교체한다. 인프라 중복 제거(이미 매일 cron으로 수집 중) + Naver 차단 회피 + LLM 입력 풍부화(summary 포함).
|
||||||
|
|
||||||
|
본 슬라이스는 **Phase 1** 전략의 일부. 4주 IC 측정 결과를 보고 (a) IC < 0.05 → 노드 폐기, (b) IC ≥ 0.05 → Phase 2 (DART OpenAPI 추가) 결정.
|
||||||
|
|
||||||
|
**Why**: adversarial review에서 가장 강한 비판이 **"이미 매일 수집 중인 `articles` 테이블을 무시하고 Naver를 100번 더 긁는 중복 인프라"**였음. weight=0 차단(이전 슬라이스 `943f676`)과 짝을 이루어 본 슬라이스로 인프라 중복 해소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
**포함 (Phase 1)**:
|
||||||
|
- 신규 모듈 `ai_news/articles_source.py` — 기존 articles 테이블 조회 + 종목명 substring 매핑
|
||||||
|
- `news_sentiment` 테이블에 `source TEXT NOT NULL DEFAULT 'articles'` 컬럼 추가
|
||||||
|
- `pipeline.py` 가 articles_source 사용 (Naver scraper 호출 제거)
|
||||||
|
- `analyzer.py` 가 LLM 입력에 `summary` 추가 (제목 + 요약)
|
||||||
|
- 텔레그램 메시지에 매핑 hit-rate 표시 (e.g., "matched 42/100")
|
||||||
|
- 단위 테스트 — articles_source 6개, pipeline 통합 회귀
|
||||||
|
|
||||||
|
**범위 외 (NOT)**:
|
||||||
|
- DART OpenAPI 통합 (Phase 2, IC 검증 후)
|
||||||
|
- alias dict / LLM ticker 추출 (Phase 1.5, hit-rate 낮을 시)
|
||||||
|
- failure taxonomy (별도 슬라이스)
|
||||||
|
- legacy `scraper.py` 삭제 (Phase 2 결정 후)
|
||||||
|
- 환경변수로 source 토글 fallback (YAGNI)
|
||||||
|
- weight 변경 (여전히 0.0 유지)
|
||||||
|
- 매핑 정확도 자동 alarm/threshold
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
[08:00 KST 평일] │ agent-office on_ai_news_ │
|
||||||
|
│ schedule (변경 없음) │
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│ HTTP POST
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ stock-lab /snapshot/refresh-news-sentiment (변경 없음) │
|
||||||
|
│ │
|
||||||
|
│ ai_news/pipeline.refresh_daily(asof): │
|
||||||
|
│ 1. top-100 tickers by market_cap (그대로) │
|
||||||
|
│ 2. articles_source.gather_articles_for_tickers(...) │
|
||||||
|
│ - SELECT * FROM articles WHERE crawled_at >= asof-1d│
|
||||||
|
│ - 각 article (title+summary) ∋ ticker.name 매칭 │
|
||||||
|
│ - {ticker: [article_dict, ...]} 반환 │
|
||||||
|
│ 3. asyncio.gather (매핑된 ticker만): │
|
||||||
|
│ a. analyzer.score_sentiment(llm, ticker, articles) │
|
||||||
|
│ (Naver scraper 호출 없음 — articles 그대로 전달) │
|
||||||
|
│ 4. news_sentiment upsert with source='articles' │
|
||||||
|
│ 5. 텔레그램 페이로드: matched_count / total_count 추가 │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**의존성 변경 없음**: anthropic SDK 유지, httpx/BeautifulSoup 제거하지 않음 (legacy scraper에서 import 유지).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 파일 변경
|
||||||
|
|
||||||
|
### 4.1 신규
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/ai_news/
|
||||||
|
articles_source.py ← DB articles 조회 + 종목 매핑
|
||||||
|
web-backend/stock-lab/tests/
|
||||||
|
test_ai_news_articles_source.py ← 6 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 수정
|
||||||
|
```
|
||||||
|
web-backend/stock-lab/app/screener/
|
||||||
|
schema.py ← news_sentiment.source 컬럼 + migration
|
||||||
|
ai_news/pipeline.py ← scraper 호출 제거, articles_source 사용
|
||||||
|
ai_news/analyzer.py ← summary 활용
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 변경 없음
|
||||||
|
- `ai_news/scraper.py` (deprecate 주석만, 다음 슬라이스에서 삭제 결정)
|
||||||
|
- `ai_news/telegram.py` (매핑 통계는 router 에서 처리하거나 telegram 빌더에 인자 추가)
|
||||||
|
- `ai_news/validation.py` (IC 측정은 데이터 소스 무관)
|
||||||
|
- `nodes/ai_news.py`
|
||||||
|
- `engine.py`
|
||||||
|
- `router.py` (응답 구조는 동일, 새 통계 필드만 추가)
|
||||||
|
- agent-office 전체
|
||||||
|
- 프론트엔드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DB 스키마 변경
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE news_sentiment ADD COLUMN source TEXT NOT NULL DEFAULT 'articles';
|
||||||
|
```
|
||||||
|
|
||||||
|
`schema.py` 의 `ensure_screener_schema(conn)` 에 migration block:
|
||||||
|
```python
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(news_sentiment)").fetchall()}
|
||||||
|
if "source" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE news_sentiment ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 운영 row (Naver 출처)는 default `'articles'` 로 채워짐 — 이는 의미적으로 부정확하지만 다음 cron부터 실제 articles 출처로 upsert되어 덮어쓰여짐. 24시간 내 정확화. Phase 2 비교 시점(4주 후)에는 충분히 cleared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `articles_source.py` 구현
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_articles_for_tickers(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tickers: List[str],
|
||||||
|
asof: dt.date,
|
||||||
|
*,
|
||||||
|
window_days: int = 1,
|
||||||
|
max_per_ticker: int = 5,
|
||||||
|
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
|
||||||
|
"""Returns ({ticker: [article, ...]}, stats)."""
|
||||||
|
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
|
||||||
|
|
||||||
|
# 1. tickers 의 회사명 조회
|
||||||
|
if not tickers:
|
||||||
|
return {}, {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
|
||||||
|
placeholders = ",".join("?" * len(tickers))
|
||||||
|
name_rows = conn.execute(
|
||||||
|
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
|
||||||
|
tickers,
|
||||||
|
).fetchall()
|
||||||
|
name_map = {r[0]: r[1] for r in name_rows if r[1]}
|
||||||
|
|
||||||
|
# 2. 최근 articles 조회
|
||||||
|
articles = conn.execute(
|
||||||
|
"SELECT title, summary, press, pub_date, crawled_at "
|
||||||
|
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# 3. 매핑
|
||||||
|
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
|
||||||
|
matched_pairs = 0
|
||||||
|
for a in articles:
|
||||||
|
title = (a[0] or "").strip()
|
||||||
|
summary = (a[1] or "").strip()
|
||||||
|
haystack = title + " " + summary
|
||||||
|
for ticker, name in name_map.items():
|
||||||
|
if not name or len(name) < 2:
|
||||||
|
continue
|
||||||
|
if name in haystack:
|
||||||
|
if len(out[ticker]) >= max_per_ticker:
|
||||||
|
continue
|
||||||
|
out[ticker].append({
|
||||||
|
"title": title,
|
||||||
|
"summary": summary,
|
||||||
|
"press": a[2] or "",
|
||||||
|
"pub_date": a[3] or "",
|
||||||
|
})
|
||||||
|
matched_pairs += 1
|
||||||
|
|
||||||
|
hit_tickers = sum(1 for arts in out.values() if arts)
|
||||||
|
stats = {
|
||||||
|
"total_articles": len(articles),
|
||||||
|
"matched_pairs": matched_pairs,
|
||||||
|
"hit_tickers": hit_tickers,
|
||||||
|
}
|
||||||
|
return out, stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `pipeline.py` 변경
|
||||||
|
|
||||||
|
`refresh_daily()` 의 `_make_http()` / `asyncio.Semaphore(rate_limit)` / scraper 호출 부분 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def refresh_daily(conn, asof, *, top_n=100, concurrency=10,
|
||||||
|
max_news_per_ticker=5, model=_analyzer.DEFAULT_MODEL):
|
||||||
|
started = time.time()
|
||||||
|
tickers = _top_market_cap_tickers(conn, n=top_n)
|
||||||
|
name_map = {...} # 기존 그대로
|
||||||
|
|
||||||
|
# 새: articles 매핑
|
||||||
|
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
|
||||||
|
conn, tickers, asof, window_days=1, max_per_ticker=max_news_per_ticker,
|
||||||
|
)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
async with _make_llm() as llm:
|
||||||
|
tasks = []
|
||||||
|
for t in tickers:
|
||||||
|
articles = articles_by_ticker.get(t, [])
|
||||||
|
if not articles:
|
||||||
|
continue # 매핑 0 — score 미생성
|
||||||
|
tasks.append(_process_one_articles(
|
||||||
|
t, name_map.get(t, t), articles, sem, llm, model
|
||||||
|
))
|
||||||
|
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
successes, failures = _split_results(raw_results)
|
||||||
|
if successes:
|
||||||
|
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
||||||
|
|
||||||
|
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(f) for f in failures],
|
||||||
|
"duration_sec": round(time.time() - started, 2),
|
||||||
|
"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, "model": model,
|
||||||
|
"mapping": mapping_stats, # 신규
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_one_articles(ticker, name, articles, sem, llm, model):
|
||||||
|
async with sem:
|
||||||
|
return await _analyzer.score_sentiment(llm, ticker, articles, name=name, model=model)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_make_http()` 제거. legacy scraper 의존 없음.
|
||||||
|
|
||||||
|
`_upsert_news_sentiment` 에 `source` 인자 추가:
|
||||||
|
```python
|
||||||
|
def _upsert_news_sentiment(conn, asof, rows, *, source="articles"):
|
||||||
|
iso = asof.isoformat()
|
||||||
|
data = [(
|
||||||
|
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
|
||||||
|
r["tokens_input"], r["tokens_output"], r["model"], source,
|
||||||
|
) for r in rows]
|
||||||
|
conn.executemany(
|
||||||
|
"""INSERT INTO news_sentiment
|
||||||
|
(ticker, date, score_raw, reason, news_count,
|
||||||
|
tokens_input, tokens_output, model, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ticker, date) DO UPDATE SET
|
||||||
|
score_raw=excluded.score_raw, reason=excluded.reason,
|
||||||
|
news_count=excluded.news_count, tokens_input=excluded.tokens_input,
|
||||||
|
tokens_output=excluded.tokens_output, model=excluded.model,
|
||||||
|
source=excluded.source
|
||||||
|
""", data,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `analyzer.py` 변경 (미세)
|
||||||
|
|
||||||
|
`news_block` 빌더만:
|
||||||
|
```python
|
||||||
|
def _format_news_block(news: List[Dict[str, Any]]) -> str:
|
||||||
|
lines = []
|
||||||
|
for n in news:
|
||||||
|
date = n.get("pub_date", "")
|
||||||
|
title = n["title"]
|
||||||
|
summary = (n.get("summary") or "").strip()
|
||||||
|
if summary:
|
||||||
|
lines.append(f"- [{date}] {title}\n {summary[:200]}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- [{date}] {title}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
`score_sentiment()` 의 prompt 빌드 부분:
|
||||||
|
```python
|
||||||
|
news_block = _format_news_block(news)
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM 입력 토큰 ~2-3배 (summary 200자 cap). 매핑 수가 감소(예상 100 → 30-60)하므로 총 토큰 비용은 비슷하거나 약간 감소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 텔레그램 매핑 통계 표시
|
||||||
|
|
||||||
|
`telegram.build_message()` 에 `mapping` 인자 추가:
|
||||||
|
```python
|
||||||
|
def build_message(*, asof, top_pos, top_neg, tokens_input, tokens_output,
|
||||||
|
mapping=None):
|
||||||
|
...
|
||||||
|
cost = _cost_won(tokens_input, tokens_output)
|
||||||
|
mapping_line = ""
|
||||||
|
if mapping:
|
||||||
|
mapping_line = (
|
||||||
|
f"매핑: {mapping['hit_tickers']}/100 ticker "
|
||||||
|
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}건\\) · "
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"_분석: 시총 상위 100종목 · {mapping_line}"
|
||||||
|
f"토큰 {tokens_input:,} in / {tokens_output:,} out · 약 ₩{cost:,}_",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
`router.py` 에서 `mapping=summary.get('mapping')` 전달.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 테스트 전략
|
||||||
|
|
||||||
|
### 10.1 신규 `test_ai_news_articles_source.py` (6 tests)
|
||||||
|
1. **single_ticker_match_in_title** — title 에 회사명 → 매핑 hit
|
||||||
|
2. **single_ticker_match_in_summary** — summary 에 회사명 → 매핑 hit
|
||||||
|
3. **multi_ticker_match** — 한 article 이 두 회사명 포함 → 두 ticker 모두 매핑
|
||||||
|
4. **no_match_returns_empty_list** — 회사명 미포함 article → 빈 리스트
|
||||||
|
5. **max_per_ticker_caps_results** — 6개 매핑 가능한 articles 중 max=5
|
||||||
|
6. **window_days_filters_old_articles** — crawled_at < cutoff 인 article 제외
|
||||||
|
|
||||||
|
### 10.2 갱신 `test_ai_news_pipeline.py`
|
||||||
|
기존 `patch.object(pipeline, "_scraper")` 패턴을 `patch.object(pipeline, "articles_source")` 로 교체. 시나리오:
|
||||||
|
- happy path: 3 ticker × 1 article each
|
||||||
|
- failures isolated: 한 ticker LLM error
|
||||||
|
- 매핑 0 ticker (skip 검증)
|
||||||
|
|
||||||
|
### 10.3 갱신 `test_ai_news_analyzer.py`
|
||||||
|
- `news` 입력에 `summary` 가 있을 때 prompt 에 포함되는지
|
||||||
|
- summary 없을 때 title 만 사용
|
||||||
|
- pub_date 표시
|
||||||
|
|
||||||
|
### 10.4 갱신 `test_ai_news_telegram.py`
|
||||||
|
- `mapping` 인자 있을 때 매핑 라인 포함
|
||||||
|
- `mapping=None` 일 때 기존 동작
|
||||||
|
|
||||||
|
### 10.5 갱신 `test_ai_news_router.py`
|
||||||
|
- response 에 `mapping` 필드 포함
|
||||||
|
|
||||||
|
### 10.6 갱신 `test_screener_schema.py`
|
||||||
|
- migration 시 `source` 컬럼 생성
|
||||||
|
- 기존 row 의 source default 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 운영 가정 + 모니터링
|
||||||
|
|
||||||
|
| 가정 | 모니터링 |
|
||||||
|
|------|----------|
|
||||||
|
| 기존 `stock_news` cron (7:30 KST)이 articles 매일 수집 | 그게 깨지면 ai_news 도 0 결과 — articles 일별 count 별도 모니터링 권장 (이번 슬라이스 외) |
|
||||||
|
| 시장 뉴스에 시총 상위 100종목 회사명이 자주 등장 | hit-rate 텔레그램 라인으로 일별 확인. <30% 면 alias dict 추가 검토 |
|
||||||
|
| 회사명 substring match가 false positive 적음 | 4주 IC 결과로 검증 (positive면 매핑 정확도 OK 추정) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 에러 처리
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| articles 테이블 비어 있음 | gather() 반환 = `{}`, stats `total=0`. 모든 ticker skip, news_sentiment 0 row 추가, telegram에 "매핑 0/100" 표시 |
|
||||||
|
| 시총 상위 ticker 모두 매핑 0 | `updated=0` → on_ai_news_schedule 의 운영자 알림 분기 (기존 그대로) |
|
||||||
|
| krx_master 비어 있음 | gather() 가 빈 결과, 위와 동일 |
|
||||||
|
| LLM 실패 (특정 ticker) | 기존 fail-soft 그대로. failures 리스트에 추가, 다른 ticker 영향 없음 |
|
||||||
|
| migration 실행 실패 (예: 이미 컬럼 존재) | PRAGMA table_info 체크로 idempotent. ALTER 안 실행 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 비용 / 성능 비교
|
||||||
|
|
||||||
|
| 항목 | 현재 (Naver) | Phase 1 (articles) |
|
||||||
|
|------|--------------|-------------------|
|
||||||
|
| 외부 HTTP | 100건/일 (Naver) | 0건 |
|
||||||
|
| 실패율 | 30%+ (Naver 차단) | 0% (DB 조회) |
|
||||||
|
| LLM calls | 100 | hit_tickers 수 (예상 30-60) |
|
||||||
|
| LLM input tokens | ~25K | ~30-50K (summary 포함) |
|
||||||
|
| 일 비용 | ~$0.075 | ~$0.05-0.10 (실측 후) |
|
||||||
|
| 처리 시간 | 30-60초 | 5-15초 (DB + LLM) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Rollback
|
||||||
|
|
||||||
|
- 데이터: `news_sentiment.source` 컬럼으로 Phase 1 데이터와 이전 Naver 데이터 구분 가능
|
||||||
|
- 코드: `git revert` 만으로 가능. legacy `scraper.py` 유지로 코드 회복 즉시
|
||||||
|
- 환경변수 토글: **미포함** (YAGNI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 후속 슬라이스 (Phase 1 이후 결정)
|
||||||
|
|
||||||
|
- **Phase 1.5** — 매핑 hit-rate < 30% 면 alias dict 추가 (50-100개)
|
||||||
|
- **Phase 2** — 4주 IC ≥ 0.05 시 DART OpenAPI 추가 (하이브리드 점수)
|
||||||
|
- **Phase X** — IC < 0.05 시 노드 deprecate 후 삭제 (scraper + analyzer + pipeline + node + DB cleanup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 완료 조건 (Definition of Done)
|
||||||
|
|
||||||
|
- [ ] `articles_source.py` + 6개 단위 테스트
|
||||||
|
- [ ] `news_sentiment.source` 컬럼 추가 + migration
|
||||||
|
- [ ] `pipeline.py` 가 articles_source 사용 (scraper 호출 없음)
|
||||||
|
- [ ] `analyzer.py` 가 summary 포함 prompt
|
||||||
|
- [ ] `telegram.py` 에 매핑 통계 라인
|
||||||
|
- [ ] `router.py` 응답에 `mapping` 필드
|
||||||
|
- [ ] 기존 76 단위 테스트 + 갱신/신규 테스트 모두 통과
|
||||||
|
- [ ] 운영 환경 트리거 시 텔레그램에 "매핑 N/100" 표시 + news_sentiment 행에 source='articles'
|
||||||
|
- [ ] LLM 비용이 일 ~$0.05-0.10 범위로 감소 (텔레그램 ₩ 라인으로 확인)
|
||||||
|
- [ ] 첫 실행 후 매핑 hit-rate 메모리 기록 (1.5/2 결정 baseline)
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# Confidence Signal Pipeline V2 — Architecture & Contract (Phase 0)
|
||||||
|
|
||||||
|
**작성일**: 2026-05-15
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation (Phase 0 = architecture decisions, 코드 변경 없음)
|
||||||
|
**Amended 2026-05-15**: Chronos-2 채택 (LSTM 폐기) + Qwen3 14B 채택 (Claude Haiku 폐기). 모델 결정 11개 보정.
|
||||||
|
**선행 컨텍스트**:
|
||||||
|
- adversarial review (2026-05-13) — 신호 검증 인프라 필요성
|
||||||
|
- Stock Screener V1 (post-close 16:30 Top-N) — 가치 발굴 완성
|
||||||
|
- AI News Phase 1 (`articles` source, weight=0 검증 대기) — sentiment 신호
|
||||||
|
- web-ai (Windows GPU, RTX 5070 Ti) — LSTM + KIS API + Telegram Bot 기존 자산
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 비전
|
||||||
|
|
||||||
|
**"주식을 쉽게 잘하기"** — 다층 신뢰도 시스템으로 사용자 + 아내 모두에게 확신 있는 매매 신호 전달.
|
||||||
|
|
||||||
|
V1 screener는 종가 기반 일별 Top-N 만 산출. V2는:
|
||||||
|
- **가치 발굴 (stock-lab 종가 기반)** ×
|
||||||
|
- **시점 분석 (web-ai 장중 Chronos-2 + 분봉)** ×
|
||||||
|
- **2차 검증 (agent-office → web-ai Qwen3 14B Ollama)** ×
|
||||||
|
- **이중 텔레그램 (본인 = 기술 풀 / 아내 = 간소화)**
|
||||||
|
= **확신의 신호**
|
||||||
|
|
||||||
|
**역할 분리 — 두 AI 모델**:
|
||||||
|
- **Chronos-2** (Amazon, 120M params, FP16 ~1GB) = 시계열 예측 엔진 (수치 → quantile 분포)
|
||||||
|
- **Qwen3 14B Q4** (Ollama, ~8.3GB) = 분석가/개발자 보조 두뇌 (자연어 메시지 + 전략 해석 + 코드 자동화)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Phase 0 산출물
|
||||||
|
|
||||||
|
**본 spec 1 문서**. 코드 변경 0. 후속 Phase 1-7 의 모든 구현이 본 spec 의 결정을 따른다.
|
||||||
|
|
||||||
|
핵심 결정 8개 (amend 시점):
|
||||||
|
1. 데이터 채널 — `web-ai pull from stock-lab` (web-ai 가 polling)
|
||||||
|
2. 데이터 소스 — KIS API 직접 (web-ai) + stock-lab API (settings/screener/portfolio)
|
||||||
|
3. **시점 예측 모델 — Chronos-2 (Amazon, 120M, zero-shot, quantile 분포)**
|
||||||
|
4. **2차 검증 모델 — Qwen3 14B Q4 (Ollama on web-ai, ~8.3GB, 응답 ~13초)**
|
||||||
|
5. 2차 검증 방식 — context augmentation (메시지 직접 작성 + 양방향 게이트)
|
||||||
|
6. 트리거 — 매수 (screener Top-20) + 매도 (portfolio 보유). 관심종목은 백로그
|
||||||
|
7. 이중 텔레그램 — 본인 풀버전 + 아내 간소화. LLM 단일 콜에서 양쪽 생성
|
||||||
|
8. 운영 — 시간대별 폴링 주기 (장전 5분 / 장중 1분 / 장후 5분 / 야간 없음 — Chronos-2 zero-shot)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 시스템 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ NAS (Synology Docker) │ │ Windows PC (RTX 5070 Ti) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────────────────────────────────┐ │ │ ┌─────────────────────────────┐ │
|
||||||
|
│ │ stock-lab :18500 │ │ │ │ web-ai :8001 │ │
|
||||||
|
│ │ • /screener/settings │◄─┼──────┼─►│ ① Pull Worker │ │
|
||||||
|
│ │ • /screener/run │ │ HTTP │ │ (시간대별 폴링) │ │
|
||||||
|
│ │ • /portfolio │ │ pull │ │ │ │
|
||||||
|
│ │ • /news-sentiment (옵션) │ │ │ │ ② KIS Client │ │
|
||||||
|
│ └────────────────────────────────┘ │ │ │ (WebSocket 분봉/호가) │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌────────────────────────────────┐ │ │ │ ③ Chronos-2 Predictor │ │
|
||||||
|
│ │ agent-office :18900 │◄─┼──────┼──┤ (Chronos-2 120M zero-shot)│ │
|
||||||
|
│ │ • /signal (Ollama 라우팅) │ │ HTTP │ │ 60일 → quantile 분포 │ │
|
||||||
|
│ │ • Telegram dispatcher (이중) │ │ push │ │ │ │
|
||||||
|
│ │ → web-ai Ollama HTTP 호출 │ │ │ │ ④ Timing Analyzer │ │
|
||||||
|
│ └─────────┬──────────────────────┘ │ trig │ │ (분봉 모멘텀) │ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
└────────────┼──────────────────────────┘ │ │ ⑤ Signal Generator │ │
|
||||||
|
│ │ │ (매수/매도 룰) │ │
|
||||||
|
▼ │ │ │ │
|
||||||
|
┌─────────────────┐ │ │ ⑥ Rate Limiter │ │
|
||||||
|
│ Telegram │ │ │ (24h 중복 차단) │ │
|
||||||
|
│ - 본인 (full) │ │ └─────────────┬───────────────┘ │
|
||||||
|
│ - 아내 (lite) │ │ │
|
||||||
|
└─────────────────┘ └───────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**책임 분리**:
|
||||||
|
- **stock-lab**: 가치 발굴 (8 노드 + 위생 게이트 + ATR), 사용자 설정 저장, portfolio 단일 진실원
|
||||||
|
- **web-ai**: 시점 분석 (Chronos-2 + 분봉), 시그널 생성, rate limit, **Ollama LLM 호스팅 (Qwen3 14B Q4)**
|
||||||
|
- **agent-office**: 신호 라우팅 (web-ai Ollama HTTP 호출), 텔레그램 발송 (본인 + 아내)
|
||||||
|
- **web-ui**: stock-lab settings 편집 (캔버스 UI). 신호 수신/표시는 V2 NOT.
|
||||||
|
|
||||||
|
**VRAM 분배 (RTX 5070 Ti 16GB, usable 15.5GB)**:
|
||||||
|
- Chronos-2: ~1GB
|
||||||
|
- Qwen3 14B Q4: ~8.3GB
|
||||||
|
- 합: ~9.3GB
|
||||||
|
- 여유: ~6GB (안전 마진)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 데이터 소스 분담
|
||||||
|
|
||||||
|
| 데이터 | 출처 | 갱신 주기 | 저장소 |
|
||||||
|
|--------|------|----------|-------|
|
||||||
|
| KRX 일봉 60일 (Chronos-2 입력) | KIS API (web-ai 직접) | 시작 시 + 종가 후 갱신 | web-ai 로컬 |
|
||||||
|
| 정규장 분봉/실시간 호가 | KIS API WebSocket (web-ai 직접) | 실시간 | web-ai 메모리 |
|
||||||
|
| NXT 가격 스냅샷 (장전/장후) | KIS API + 네이버 모바일 백업 | 30초~1분 폴링 | web-ai 로컬 |
|
||||||
|
| screener settings (가중치) | stock-lab API (web-ai pull) | 1-5분 | NAS `stock.db` |
|
||||||
|
| screener 점수 (Top-20) | stock-lab `/run` 호출 결과 | 1-5분 | NAS (preview 모드, 미저장) |
|
||||||
|
| portfolio (보유 종목 + 평단) | stock-lab API (web-ai pull) | 1-5분 | NAS `stock.db` |
|
||||||
|
| 외인/기관 수급 | stock-lab (네이버 frgn) | 종가 후 16:30 | NAS `stock.db` |
|
||||||
|
| AI 뉴스 sentiment | stock-lab (articles 기반 Claude) | 평일 08:00 | NAS `stock.db` |
|
||||||
|
| 사용자 텔레그램 chat IDs | agent-office 환경변수 | 정적 | docker-compose env |
|
||||||
|
|
||||||
|
**원칙**:
|
||||||
|
- web-ai는 NAS DB 직접 접근 안 함 — 모든 데이터는 stock-lab API 경유
|
||||||
|
- KIS API 데이터는 web-ai 로컬에만 — NAS push 안 함 (실시간성 + 용량)
|
||||||
|
- 본인+아내 chat ID 는 agent-office 단독 보관 — web-ai 는 ticker/action 만 push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 계약
|
||||||
|
|
||||||
|
### 5.1 stock-lab → web-ai (pull 응답)
|
||||||
|
|
||||||
|
**기존 endpoint (변경 없음)**:
|
||||||
|
- `GET /api/stock/screener/settings` — 현재 가중치/임계값
|
||||||
|
- `POST /api/stock/screener/run {mode:"preview"}` — 8 노드 점수 + Top-N (DB 미저장)
|
||||||
|
- `GET /api/portfolio` — 보유 종목 리스트
|
||||||
|
|
||||||
|
**신규 endpoint (Phase 1)**:
|
||||||
|
- `GET /api/stock/screener/news-sentiment?days=1` — 종목별 sentiment 점수 (옵션, Phase 1 에 추가)
|
||||||
|
|
||||||
|
### 5.2 web-ai → agent-office (push)
|
||||||
|
|
||||||
|
**신규 endpoint** (Phase 5):
|
||||||
|
```
|
||||||
|
POST /api/agent-office/signal
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticker": "005930",
|
||||||
|
"name": "삼성전자",
|
||||||
|
"action": "buy" | "sell",
|
||||||
|
"confidence_webai": 0.82,
|
||||||
|
"current_price": 78500,
|
||||||
|
"avg_price": 75000, // sell 시에만
|
||||||
|
"pnl_pct": 0.047, // sell 시에만
|
||||||
|
"context": {
|
||||||
|
"lstm_pred_1d": 0.023,
|
||||||
|
"lstm_pred_conf": 0.82,
|
||||||
|
"screener_rank": 3,
|
||||||
|
"screener_scores": {"foreign_buy": 88, "volume_surge": 75, "momentum": 60, ...},
|
||||||
|
"minute_momentum": "strong_up" | "weak_up" | "neutral" | "weak_down" | "strong_down",
|
||||||
|
"kospi_change": 0.004,
|
||||||
|
"news_sentiment": 6.2,
|
||||||
|
"news_top": ["HBM 양산 가시화", "1분기 어닝 서프라이즈"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (agent-office → web-ai):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"decision": "send" | "hold",
|
||||||
|
"final_confidence": 0.745,
|
||||||
|
"telegram_self_sent": true,
|
||||||
|
"telegram_wife_sent": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 web-ai Ollama 응답 (agent-office → Ollama HTTP)
|
||||||
|
|
||||||
|
agent-office 가 web-ai 의 Ollama (Qwen3 14B Q4) 에 보내는 prompt 의 응답 schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"decision": "send" | "hold",
|
||||||
|
"confidence_llm": 0.91,
|
||||||
|
"reason": "외인+거래량+호재 일관성 강함",
|
||||||
|
"warnings": ["KOSPI 약세 가능성"],
|
||||||
|
"message_self": "🔔 매수 신호: 삼성전자 (005930)\n💡 신뢰도 ...",
|
||||||
|
"message_wife": "📈 추천: 삼성전자 매수 검토\n사유: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`final_confidence = confidence_webai × confidence_llm`. 임계값 (default 0.7) 미만 또는 `decision="hold"` 면 silent (텔레그램 발송 안 함).
|
||||||
|
|
||||||
|
**프롬프트 엔지니어링 (Qwen3 14B JSON 강제)** — ai_news 슬라이스의 Claude JSON 강제 패턴 적용:
|
||||||
|
- system: "너는 한국 주식 분석가다. JSON 객체 하나만 반환한다."
|
||||||
|
- assistant prefill `"{"` 로 응답 시작 강제
|
||||||
|
- temperature=0
|
||||||
|
- 응답 파싱 실패 시 `decision="hold"` 폴백 (silent block)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 시그널 룰
|
||||||
|
|
||||||
|
### 6.1 매수 신호 (screener Top-20 종목 대상)
|
||||||
|
|
||||||
|
조건 (전부 충족):
|
||||||
|
1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 (90-10 분위수 / 50 분위수) < 0.6 (좁은 분포 = 높은 conf)
|
||||||
|
2. 분봉 모멘텀 = `strong_up`:
|
||||||
|
- 5분봉 5개 연속 양봉
|
||||||
|
- 거래량 > 평균 1.5배
|
||||||
|
3. KIS 호가 매수세 ≥ 60%
|
||||||
|
|
||||||
|
종합 confidence:
|
||||||
|
```
|
||||||
|
confidence_webai = chronos_conf × 0.5 + minute_score × 0.3 + screener_norm × 0.2
|
||||||
|
```
|
||||||
|
- `chronos_conf` ∈ [0, 1] — Chronos-2 분포 폭에서 변환 (좁을수록 1에 가까움)
|
||||||
|
- `minute_score` ∈ [0, 1] (5분봉 강도 + 거래량 multiplier 정규화)
|
||||||
|
- `screener_norm` = 1 - (rank - 1) / 20 (rank 1 = 1.0, rank 20 = 0.05)
|
||||||
|
|
||||||
|
**임계값**: `confidence_webai > 0.7` → agent-office 전송. 아니면 silent.
|
||||||
|
|
||||||
|
### 6.2 매도 신호 (portfolio 보유 종목 대상)
|
||||||
|
|
||||||
|
**손절선** (사용자 조정 가능, default -7%):
|
||||||
|
- `pnl_pct < -0.07` 시 즉시 매도 시그널 (Chronos-2/분봉 무관)
|
||||||
|
- 메시지: "손절선 도달, 매도 검토"
|
||||||
|
|
||||||
|
**익절선** (default +15%):
|
||||||
|
- `pnl_pct > 0.15` 시 검토 알림 (강제 매도 아님)
|
||||||
|
- 메시지: "익절선 도달, 부분 매도 또는 추세 추종 검토"
|
||||||
|
|
||||||
|
**이상 신호** (보유 중 급격한 약세):
|
||||||
|
- Chronos-2 1-day quantile (median) 예측 < -1% + 분포 폭 좁음 (chronos_conf > 0.7)
|
||||||
|
- 분봉 모멘텀 = `strong_down`
|
||||||
|
- KIS 호가 매도세 ≥ 60%
|
||||||
|
- `confidence_webai > 0.7` 동일 임계값으로 전송
|
||||||
|
|
||||||
|
### 6.3 Rate limit
|
||||||
|
|
||||||
|
- **같은 종목 + 같은 action**: 24h 내 재알림 금지
|
||||||
|
- **장 마감 후 재실행**: 손절선/익절선 알림은 1일 1회 maximum
|
||||||
|
- Rate limit state: web-ai 로컬 SQLite 또는 메모리 dict (재기동 시 reset = 운영상 허용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 텔레그램 메시지 형식
|
||||||
|
|
||||||
|
### 7.1 본인 (기술 풀)
|
||||||
|
|
||||||
|
```
|
||||||
|
🔔 매수 신호: 삼성전자 (005930)
|
||||||
|
💡 신뢰도 87/100 (web-ai 82 × Qwen3 91)
|
||||||
|
|
||||||
|
📊 분석 근거:
|
||||||
|
• Chronos-2 예측: 다음날 +2.3% (분포 폭 좁음, conf 0.82)
|
||||||
|
• Screener Top-3: 외인+거래량 강세
|
||||||
|
• AI 뉴스: +6.2 (HBM 양산 가시화)
|
||||||
|
• 분봉 모멘텀: 강세 (5분봉 5연속 양봉)
|
||||||
|
• KOSPI: +0.4% (약강세)
|
||||||
|
|
||||||
|
⚠️ 주의:
|
||||||
|
• 코스피 약세 구간 진입 가능성
|
||||||
|
• 분할 매수 권고
|
||||||
|
|
||||||
|
현재가: 78,500원
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 아내 (간소화)
|
||||||
|
|
||||||
|
```
|
||||||
|
📈 추천: 삼성전자 매수 검토
|
||||||
|
사유: 외국인 매수 강세 + 호재 뉴스
|
||||||
|
추천 강도: ★★★★☆ (높음)
|
||||||
|
현재가: 78,500원
|
||||||
|
```
|
||||||
|
|
||||||
|
추천 강도 표시: `final_confidence` 기준
|
||||||
|
- ★★★★★ (0.85+)
|
||||||
|
- ★★★★☆ (0.7-0.85)
|
||||||
|
- ★★★☆☆ (0.55-0.7) — 텔레그램 발송은 0.7 임계값이라 도달 안 함
|
||||||
|
|
||||||
|
### 7.3 매도 메시지 (본인/아내 양쪽)
|
||||||
|
|
||||||
|
본인:
|
||||||
|
```
|
||||||
|
🚨 매도 신호: SK하이닉스 (000660)
|
||||||
|
💡 신뢰도 78/100
|
||||||
|
|
||||||
|
📊 사유:
|
||||||
|
• 평단 대비 -7.2% (손절선 도달)
|
||||||
|
• Chronos-2 다음날 -1.5% 예측 (conf 0.75)
|
||||||
|
• 분봉 강한 매도세
|
||||||
|
|
||||||
|
매도 검토 권고. 평단 152,000원 → 현재 141,100원
|
||||||
|
```
|
||||||
|
|
||||||
|
아내:
|
||||||
|
```
|
||||||
|
⚠️ 매도 검토: SK하이닉스
|
||||||
|
사유: 손절선 도달, 약세 신호
|
||||||
|
손익: -7.2%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 운영 모드
|
||||||
|
|
||||||
|
| 시간대 | web-ai 동작 | 폴링 주기 | 비용 |
|
||||||
|
|--------|------------|----------|------|
|
||||||
|
| **장전 (07:00-09:00)** | settings + screener pull + NXT 가격 + sentiment | 5분 | 0 |
|
||||||
|
| **장중 (09:00-15:30)** | KIS 분봉 + 호가 + Chronos-2 추론 + 시그널 + Qwen3 검증 | 1분 | 0 (LLM 로컬) |
|
||||||
|
| **장후 (15:30-20:00)** | NXT 가격 + 보유 종목 PnL 추적 + 손절/익절 알림 | 5분 | 0 |
|
||||||
|
| **야간 (20:00-07:00)** | (재학습 cron 없음 — Chronos-2 zero-shot) | — | 0 |
|
||||||
|
|
||||||
|
**예상 LLM 비용**:
|
||||||
|
- **월 LLM API 비용 = 0** (Qwen3 14B Q4 로컬 호스팅)
|
||||||
|
- 전기료만 (Windows PC 상시 가동, RTX 5070 Ti 평균 idle ~30W + 추론 spike ~200W)
|
||||||
|
- 일 신호 3-5건 × ~13초 추론 = 일 GPU full load ~1분 정도, 무시 가능
|
||||||
|
- **Chronos-2 추론은 GPU 로컬, 비용 0**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phase 1-7 분해
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: stock-lab API 보강 (1주)
|
||||||
|
- /api/portfolio 외부 노출 (현재 web-ui 내부용)
|
||||||
|
- /api/stock/screener/news-sentiment endpoint 추가
|
||||||
|
- /api/stock/screener/run preview 옵션 검증
|
||||||
|
|
||||||
|
Phase 2: web-ai Pull Worker + Signal API Client (2주)
|
||||||
|
- 기존 main_server.py + bot.py 분리
|
||||||
|
- stock-lab API client (httpx + retry + cache)
|
||||||
|
- 시간대별 폴링 스케줄러
|
||||||
|
- rate limit DB
|
||||||
|
|
||||||
|
Phase 3: KIS WebSocket + 분봉 + Chronos-2 추론 (2주, ↓ 1주)
|
||||||
|
- KIS WebSocket client (정규장 분봉 + 호가)
|
||||||
|
- NXT 폴링 client (스냅샷 + 네이버 백업)
|
||||||
|
- Chronos-2 zero-shot 추론 파이프라인 (HuggingFace 모델 로드 + 배치 추론)
|
||||||
|
- 분봉 모멘텀 분류기
|
||||||
|
- (재학습 인프라 X — Chronos-2 zero-shot)
|
||||||
|
|
||||||
|
Phase 4: Signal Generator (1주)
|
||||||
|
- 매수 룰 (Chronos-2 quantile + 분봉 + 호가 + screener)
|
||||||
|
- 매도 룰 (손절/익절/이상)
|
||||||
|
- confidence 계산 + 임계값
|
||||||
|
|
||||||
|
Phase 5: agent-office /signal + Ollama Qwen3 검증 + 이중 텔레그램 (2주)
|
||||||
|
- POST /signal 라우터 (agent-office)
|
||||||
|
- web-ai 에 Ollama 서버 + Qwen3 14B Q4 설치
|
||||||
|
- agent-office → web-ai Ollama HTTP client (Anthropic SDK 대체)
|
||||||
|
- Qwen3 prompt (system + user + assistant prefill JSON)
|
||||||
|
- 본인/아내 dispatcher
|
||||||
|
- **A/B 테스트 1주 — 본인 chat 에 Qwen3/Claude Haiku 메시지 동시 발송 후 한 쪽 채택**
|
||||||
|
|
||||||
|
Phase 6: web-ai 기존 trading bot 정리 (1주)
|
||||||
|
- 자체 watchlist_manager 삭제
|
||||||
|
- 자체 뉴스 크롤링 (Ollama) 삭제
|
||||||
|
- 기존 자동 매매 (KIS 실주문) 비활성화 또는 별도 모드 분리
|
||||||
|
|
||||||
|
Phase 7: 운영 모니터링 + 4주 IC 검증 (1주 + 4주)
|
||||||
|
- 신호 hit-rate 추적 (forward return correlation)
|
||||||
|
- false positive rate
|
||||||
|
- 임계값 점진 조정
|
||||||
|
- Phase 8 (자동 매매) 검토
|
||||||
|
```
|
||||||
|
|
||||||
|
총 10-12주 (개인 페이스). 각 Phase 마다 자체 spec + plan + 검증 사이클.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Backlog (V2 본 spec NOT)
|
||||||
|
|
||||||
|
미래 슬라이스로 분리:
|
||||||
|
- **관심종목 (watchlist) 모니터링** — Top-N + portfolio 외, 사용자 관심종목의 변동성 spike / 거래량 급증 알람
|
||||||
|
- **자동 매매 (KIS 실주문)** — Phase 8 검토. 4주 신호 hit-rate ≥ 60% 후 단계적
|
||||||
|
- **DART 공시 통합** — LLM 검증 컨텍스트에 공시 추가
|
||||||
|
- **백테스트 화면** — 과거 신호 정확도 시각화
|
||||||
|
- **신호 hit-rate 대시보드** — web-ui 신규 페이지
|
||||||
|
- **분할 매수/매도 전략 추천** — Phase 7 이후
|
||||||
|
- **옵션/선물/해외 주식** — V3 검토
|
||||||
|
- **Qwen3 14B "개발자 보조" 별도 endpoint** — 전략 해석/코드 자동화/디버그 도구. V2 흐름 외 사용자 챗봇 형태 (텔레그램 또는 web-ui chat). 같은 Ollama 인스턴스 재활용
|
||||||
|
- **Claude API 폴백** — web-ai/Ollama 장애 시 anthropic 으로 자동 전환 (가용성 보강)
|
||||||
|
- **Kimi K2.6 API 옵션** — Qwen3 응답 품질 부족 시 ~80% 저비용 외부 API 대안
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| Windows PC 다운 시 신호 zero | stock-lab은 정상. web-ai down 시 헬스체크 → 텔레그램 운영자 알림. Ollama도 함께 다운 (같은 머신) → Claude API 폴백은 백로그 |
|
||||||
|
| KIS API 장애 | NXT는 네이버 모바일 API 폴백. 분봉은 단기 재시도 + 일정 시간 후 alert |
|
||||||
|
| **Qwen3 14B 한국어 메시지 품질 부족** | **Phase 5 A/B 테스트 1주 — Qwen3 vs Claude Haiku 메시지 동시 발송 후 우월한 쪽 채택. Qwen3 부족 시 Claude Haiku 로 폴백** |
|
||||||
|
| False positive 다수 | 4주 IC + Phase 7 모니터링. 임계값 점진 상향 |
|
||||||
|
| Chronos-2 분포 drift | 주간 ablation (forward return correlation 추적). drift 시 다른 foundation 모델 (Moirai-2.0) 으로 교체 검토 |
|
||||||
|
| 메시지 본인-아내 drift | LLM 단일 콜에서 양쪽 동시 생성 (drift 회피, 같은 reasoning) |
|
||||||
|
| 매도 신호 지연 | 분봉 1분 폴링. 손절선은 보유 종목 단순 비교 (Chronos-2 무관 즉시 트리거) |
|
||||||
|
| stock-lab API 응답 지연 | web-ai 측 timeout 10s + 캐시 (마지막 성공 응답 ttl 5분) |
|
||||||
|
| 종목 갱신 race condition | screener Top-20 변동 시 rate limit 키 = (ticker, action, date) |
|
||||||
|
| **Qwen3 응답 13초로 분봉 1분 안에 한 사이클 끝낼 수 없을 위험** | 신호 발생 빈도 일 3-5건이라 동시 처리 거의 없음. 큐 직렬 처리로 충분. 대량 신호 시 backpressure → Phase 7 모니터링 |
|
||||||
|
| **VRAM 빡빡 (Chronos-2 + Qwen3 = 9.3GB / 15.5GB)** | 여유 6GB 안전. 동시 로딩 시점 분리 (Chronos-2 추론 → 결과 메모리 보관 → Qwen3 호출). swap 발생 시 Phase 7 에서 Qwen3 8B 로 다운그레이드 검토 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 명시적 NOT 범위 (Phase 0)
|
||||||
|
|
||||||
|
- **자동 매매 (실주문)**: V2 는 신호만. 사용자가 수동 매매. Phase 8 별도 검토
|
||||||
|
- **종목 매수 가격/수량 추천**: 사용자 결정. 신호는 "검토 권고" 수준
|
||||||
|
- **분할 매수/매도 전략**: Phase 7 이후 별도 슬라이스
|
||||||
|
- **옵션/선물/해외 주식**: KRX 정규장 + NXT 한정
|
||||||
|
- **관심종목 모니터링**: 백로그 (§10)
|
||||||
|
- **신호 hit-rate 시각화 UI**: 백로그
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 완료 조건 (Phase 0 DoD)
|
||||||
|
|
||||||
|
본 spec 완료 = 다음 조건 모두 충족:
|
||||||
|
- [x] 사용자가 spec 검토 + 승인 (2026-05-15)
|
||||||
|
- [x] git commit (`docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- [x] 8 핵심 결정 명시적 (데이터 채널, 데이터 소스, Chronos-2 예측, Qwen3 검증, context augmentation, 매수+매도, 이중 텔레그램, 운영 모드)
|
||||||
|
- [x] 4개 API 계약 (3 stock-lab pull + 1 agent-office push) 모두 schema 정의
|
||||||
|
- [x] Phase 1-7 분해 + 각 Phase 추정 기간 (Phase 3 -1주, Phase 5 +0주 → 총 10-11주)
|
||||||
|
- [x] backlog + 위험/완화 매트릭스 + NOT 범위
|
||||||
|
- [x] **Amend (2026-05-15): Chronos-2 + Qwen3 14B Q4 채택 + 11 보정**
|
||||||
|
|
||||||
|
Phase 0 자체에는 코드 변경 0. 본 spec 승인 후 Phase 1 brainstorming 으로 자연스럽게 이어진다.
|
||||||
214
docs/superpowers/specs/2026-05-15-stock-lab-rename-to-stock.md
Normal file
214
docs/superpowers/specs/2026-05-15-stock-lab-rename-to-stock.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# stock-lab → stock 리네이밍 Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-15
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**: Confidence Signal Pipeline V2 Phase 0 (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`stock-lab` 컨테이너/디렉토리/환경변수의 `-lab` 접미사를 제거해 **stock** 으로 graduation. lab 네이밍 규칙 (`feedback_lab_naming.md`) 에 따라 정식 서비스로 명확화.
|
||||||
|
|
||||||
|
본 리네이밍은 **Confidence Signal Pipeline V2 Phase 1** 작업 시작 전 선행. 이름이 stock-lab인 채로 Phase 1 spec/plan/code 가 작성되면 다시 갱신하는 비용 회피.
|
||||||
|
|
||||||
|
**Why**: 메모리 `feedback_lab_naming.md` 정책 — "-lab은 개발/연구 단계에만, 정식 서비스에는 미사용". stock 서비스는 (a) 8 노드 screener 완성, (b) 캔버스 UI, (c) AI 뉴스 Phase 1, (d) V2 시그널 파이프라인의 중심 = 정식 graduation 단계.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
**포함**:
|
||||||
|
- web-backend 디렉토리 `git mv stock-lab stock`
|
||||||
|
- `docker-compose.yml` 4 곳 갱신
|
||||||
|
- agent-office 환경변수 `STOCK_LAB_URL` → `STOCK_URL` 코드 + 컴포즈
|
||||||
|
- nginx config (`nginx/default.conf` in web-backend repo) `upstream stock-lab` → `stock`
|
||||||
|
- 운영 문서 (`web-backend/CLAUDE.md`, `README.md`, `STATUS.md`, scripts)
|
||||||
|
- workspace `CLAUDE.md` + web-ui `CLAUDE.md`
|
||||||
|
- 메모리 4개 (`project_workspace.md`, `project_scale.md`, `project_stock_screener.md`, `nas_infra.md`)
|
||||||
|
- 메모리 정책 추가 (`feedback_lab_naming.md` 에 stock graduation 케이스 등재)
|
||||||
|
|
||||||
|
**범위 외 (NOT)**:
|
||||||
|
- API URL 경로 (`/api/stock/...` 그대로)
|
||||||
|
- Python `app.*` import 경로
|
||||||
|
- DB 파일명 (`stock.db` 그대로)
|
||||||
|
- frontend 라우트 (`/stock/*` 그대로)
|
||||||
|
- 다른 lab 의 이름 (lotto/music-lab/blog-lab/realestate-lab/packs-lab/travel-proxy 모두 그대로)
|
||||||
|
- 과거 spec/plan 문서 (`docs/superpowers/specs|plans/2026-05-*.md`) — 역사적 기록 유지
|
||||||
|
- `.venv` 디렉토리 — gitignore, 사용자 로컬에서 재생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 변경 매트릭스
|
||||||
|
|
||||||
|
### 3.1 web-backend 코드 (필수)
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `stock-lab/` → `stock/` | `git mv` |
|
||||||
|
| `docker-compose.yml` | service key `stock-lab` → `stock` (1) / container_name `stock-lab` → `stock` (1) / build.context `./stock-lab` → `./stock` (1) / frontend.depends_on의 `stock-lab` → `stock` (1) |
|
||||||
|
| `agent-office/app/config.py` | `STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", ...)` → `STOCK_URL = os.getenv("STOCK_URL", ...)` |
|
||||||
|
| `agent-office/app/service_proxy.py` | `from .config import STOCK_LAB_URL` → `STOCK_URL`. 함수 본문의 `STOCK_LAB_URL` 사용처 5개 (fetch_stock_news / fetch_stock_indices / summarize_stock_news / refresh_screener_snapshot / run_stock_screener) → `STOCK_URL` |
|
||||||
|
| `agent-office/app/agents/stock.py` | `STOCK_LAB_URL` 직접 참조 시 갱신 (만약 있다면) |
|
||||||
|
| `agent-office/tests/test_stock_screener_job.py` | mock URL 또는 env var 참조 갱신 |
|
||||||
|
| `agent-office docker-compose.yml 부분` | `STOCK_LAB_URL=http://stock-lab:8000` → `STOCK_URL=http://stock:8000` |
|
||||||
|
| `nginx/default.conf` | `upstream stock-lab { server stock-lab:8000; }` → `upstream stock { server stock:8000; }` + `proxy_pass http://stock-lab` → `http://stock` |
|
||||||
|
| `web-backend/CLAUDE.md` | stock-lab 언급 모두 stock 으로 |
|
||||||
|
| `web-backend/README.md` | 동일 |
|
||||||
|
| `web-backend/STATUS.md` | 동일 |
|
||||||
|
| `web-backend/scripts/deploy-nas.sh`, `deploy.sh` | stock-lab 호출/경로 갱신 |
|
||||||
|
|
||||||
|
### 3.2 web-ui (문서만)
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `web-ui/CLAUDE.md` | stock-lab 언급을 stock 으로 (디렉토리 경로 표 포함) |
|
||||||
|
|
||||||
|
**과거 spec/plan 문서들** (`web-ui/docs/superpowers/specs|plans/2026-05-*.md`): 역사적 기록 유지 — **변경 없음**.
|
||||||
|
|
||||||
|
### 3.3 workspace 최상위
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `workspace/CLAUDE.md` | "stock-lab" 컨테이너 이름 표 + 디렉토리 경로 갱신 |
|
||||||
|
|
||||||
|
### 3.4 메모리 (controller 직접 적용)
|
||||||
|
|
||||||
|
| 메모리 | 변경 |
|
||||||
|
|--------|------|
|
||||||
|
| `project_workspace.md` | stock-lab → stock |
|
||||||
|
| `project_scale.md` | 백엔드 서비스 표의 stock-lab 행 갱신, `stock-lab/` 디렉토리 → `stock/` |
|
||||||
|
| `project_stock_screener.md` | 다수 언급 (백엔드 위치) 갱신 |
|
||||||
|
| `nas_infra.md` | Docker 서비스 포트 표 + nginx 라우팅 |
|
||||||
|
| `feedback_lab_naming.md` | stock graduation 사례 추가 (2026-05-15) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 작업 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사전 검토 (10분)
|
||||||
|
- 본 spec 의 3장 매트릭스 모든 파일이 grep 결과와 일치하는지 cross-check
|
||||||
|
- `.venv` / `__pycache__` 제외 확인
|
||||||
|
- nginx default.conf 의 정확한 변경 줄 식별
|
||||||
|
|
||||||
|
2. web-backend 디렉토리 + 컴포즈 + agent-office 코드 (한 commit)
|
||||||
|
- git mv stock-lab stock
|
||||||
|
- docker-compose.yml 4 곳
|
||||||
|
- agent-office config.py, service_proxy.py, agents/stock.py, tests/
|
||||||
|
- nginx/default.conf
|
||||||
|
- web-backend의 CLAUDE.md, README.md, STATUS.md, scripts/
|
||||||
|
|
||||||
|
3. workspace + web-ui CLAUDE.md (별도 commit, 각 repo)
|
||||||
|
- workspace/CLAUDE.md
|
||||||
|
- web-ui/CLAUDE.md
|
||||||
|
|
||||||
|
4. 메모리 갱신 (controller 직접)
|
||||||
|
- 4개 메모리 파일 + feedback_lab_naming.md graduation 케이스
|
||||||
|
|
||||||
|
5. 배포 검증
|
||||||
|
- web-backend push → Gitea webhook → deployer rsync + docker compose up
|
||||||
|
- docker logs stock --tail 30
|
||||||
|
- docker ps 에서 stock 컨테이너 healthy
|
||||||
|
- curl https://gahusb.synology.me/api/stock/news (200)
|
||||||
|
- curl https://gahusb.synology.me/api/stock/screener/runs (200)
|
||||||
|
- agent-office 다음 16:30 cron 결과 (텔레그램) 정상 도착 확인 또는 수동 트리거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| nginx config 가 옛 호스트 `stock-lab` 으로 라우팅 → 502 | nginx config 도 같은 commit 에 포함. deployer rsync 가 web-backend repo 의 nginx 폴더를 NAS runtime 에 동기화 |
|
||||||
|
| agent-office 가 옛 환경변수 `STOCK_LAB_URL` 사용 → connection refused | 컴포즈의 환경변수 항목 동시 변경. agent-office 재기동 후 새 변수 적용 |
|
||||||
|
| `.env` 파일에 `STOCK_LAB_URL=...` 남아 있으면 새 변수 빈 값 → 기본값 `http://stock:8000` fallback | service_proxy 의 `os.getenv("STOCK_URL", "http://stock:8000")` default 확인. 운영 .env 갱신은 사용자 1회 작업 |
|
||||||
|
| 다른 lab 의 stock-lab 호출 누락 | grep `STOCK_LAB_URL` 결과 5개 파일 모두 commit 에 포함. 추가 누락 시 다음 cron 실패로 즉시 발견 |
|
||||||
|
| 컨테이너 교체 다운타임 | 약 10초 (docker compose up 의 stop+start). 1인 운영 + 비치명적, 허용 |
|
||||||
|
| Python `app.*` import 경로 회귀 | 디렉토리 이름만 변경. 빌드 컨텍스트 변경으로 도커 이미지 안의 app 패키지 그대로. 회귀 없음 (76 + 신규 테스트 전부 통과 검증) |
|
||||||
|
| 메모리 갱신 누락 | grep "stock-lab" / "STOCK_LAB" 메모리 폴더 0건 검증 |
|
||||||
|
| 과거 spec/plan 문서의 stock-lab 언급 | 역사적 기록 — 의도적 보존. 미래 spec 부터 stock 사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 / 검증
|
||||||
|
|
||||||
|
### 6.1 자동 (코드 검증)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# stock-lab 잔여 참조 0건 (의도적 보존 spec/plan 제외)
|
||||||
|
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/ \
|
||||||
|
| grep -v "\.venv" | grep -v "__pycache__" | grep -v "/docs/" | grep -v "\.git"
|
||||||
|
# Expected: 0 lines
|
||||||
|
|
||||||
|
# agent-office 테스트
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office
|
||||||
|
python -m pytest tests/test_stock_screener_job.py -v
|
||||||
|
# Expected: PASS
|
||||||
|
|
||||||
|
# stock pytest
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend/stock
|
||||||
|
python -m pytest --ignore=app/test_scraper.py -q
|
||||||
|
# Expected: 76+ tests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 수동 (배포 검증)
|
||||||
|
|
||||||
|
배포 후 NAS:
|
||||||
|
```bash
|
||||||
|
docker logs stock --tail 30
|
||||||
|
docker logs agent-office --tail 20
|
||||||
|
docker ps --format "{{.Names}}: {{.Status}}" | grep stock
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저 / curl:
|
||||||
|
- `https://gahusb.synology.me/api/stock/news` → 200
|
||||||
|
- `https://gahusb.synology.me/api/stock/screener/runs` → 200
|
||||||
|
- `https://gahusb.synology.me/stock/screener` (web-ui) → 캔버스 모드 진입 정상
|
||||||
|
|
||||||
|
agent-office 수동 트리거 (다음 cron 기다리지 않고):
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent":"stock","action":"run_ai_news"}'
|
||||||
|
```
|
||||||
|
응답 `{"ok": true}` + 텔레그램 도착 → stock 호스트 라우팅 정상.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | ~10초 (컨테이너 교체) |
|
||||||
|
| 사용자 영향 | 없음 (API URL/UI 경로 그대로) |
|
||||||
|
| .env 파일 갱신 | 사용자 1회 (STOCK_LAB_URL 줄 삭제 또는 STOCK_URL 추가) |
|
||||||
|
| frontend 재배포 | 불필요 (web-ui 는 문서만 변경) |
|
||||||
|
| 다른 lab 영향 | agent-office 만 영향 (환경변수). 나머지 lab 무영향 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Phase 1 와의 관계
|
||||||
|
|
||||||
|
본 리네이밍 완료 후 즉시 **Confidence Signal Pipeline V2 Phase 1** spec 작성 (이전 발표 디자인 그대로, 새 이름 `stock` 기준). 의존성:
|
||||||
|
```
|
||||||
|
[본 리네이밍 spec/plan/실행] → [Phase 1 spec → plan → 실행]
|
||||||
|
1-2시간 1주
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `web-backend/stock-lab/` 디렉토리 사라지고 `stock/` 존재 (git history 보존)
|
||||||
|
- [ ] `docker-compose.yml` 의 4 곳 갱신
|
||||||
|
- [ ] agent-office env 변수 `STOCK_LAB_URL` 코드/컴포즈/문서에서 0건
|
||||||
|
- [ ] nginx config `upstream stock-lab` 0건, `upstream stock` 존재
|
||||||
|
- [ ] grep "stock-lab" 결과: 의도적 보존 (`docs/superpowers/*`) 외 0건
|
||||||
|
- [ ] stock pytest 76+ tests passed
|
||||||
|
- [ ] 배포 후 `docker ps` 에 `stock` 컨테이너 healthy
|
||||||
|
- [ ] curl `/api/stock/news`, `/api/stock/screener/runs` 200
|
||||||
|
- [ ] agent-office `run_ai_news` 수동 트리거 텔레그램 도착
|
||||||
|
- [ ] 메모리 4 파일 갱신 + `feedback_lab_naming.md` graduation 케이스 등재
|
||||||
Reference in New Issue
Block a user