Compare commits
37 Commits
feature/st
...
a8e411ec22
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e411ec22 | |||
| f261a80d52 | |||
| 42e9c8df27 | |||
| c84c6b5bac | |||
| 094366a162 | |||
| 3bf7ce446f | |||
| 8391919b90 | |||
| ed7e927dc1 | |||
| 309bedadeb | |||
| ebdfcd758b | |||
| cefaeca449 | |||
| cdfa31b0c1 | |||
| ec3ca5fcfa | |||
| 7ebeba2f3d | |||
| 5e66d96c61 | |||
| fde63d757b | |||
| 4b64761800 | |||
| 1449342f96 | |||
| 2effc47593 | |||
| f8574f1b45 | |||
| 2da7255c03 | |||
| b4ad0b1abf | |||
| 4e134eb59a | |||
| b1a1bb22f9 | |||
| f10fa062e9 | |||
| 40e3e2cf39 | |||
| 1505518ca6 | |||
| 2fd2ea33c7 | |||
| c60c32b7f2 | |||
| 5f95f55271 | |||
| d73ad9b851 | |||
| fdf5ef6ce8 | |||
| ca248891c2 | |||
| 55d2adeaf5 | |||
| 6fd70dd802 | |||
| 9f4363cdbb | |||
| 295972e0cb |
14
CLAUDE.md
14
CLAUDE.md
@@ -17,7 +17,7 @@
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
@@ -26,7 +26,7 @@
|
||||
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||
| `/todo` | `Todo` | 태스크 보드 |
|
||||
| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 |
|
||||
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||
|
||||
@@ -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/`)
|
||||
- 개발 서버 포트: **3007**
|
||||
|
||||
@@ -113,9 +113,11 @@ proxy: {
|
||||
| 여행 | POST | `/api/travel/sync` |
|
||||
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
|
||||
| 여행 | PUT | `/api/travel/regions/:id` |
|
||||
| 블로그마케팅 | POST | `/api/blog-marketing/research`, `/api/blog-marketing/generate` |
|
||||
| 블로그마케팅 | GET | `/api/blog-marketing/posts`, `/api/blog-marketing/dashboard` |
|
||||
| 블로그마케팅 | POST | `/api/blog-marketing/market/:id`, `/api/blog-marketing/review/:id` |
|
||||
| 인스타 | GET | `/api/insta/status`, `/api/insta/news/articles`, `/api/insta/keywords`, `/api/insta/slates`, `/api/insta/slates/:id` |
|
||||
| 인스타 | POST | `/api/insta/news/collect`, `/api/insta/keywords/extract`, `/api/insta/slates`, `/api/insta/slates/:id/render` |
|
||||
| 인스타 | DELETE | `/api/insta/slates/:id` |
|
||||
| 인스타 | GET/PUT | `/api/insta/templates/prompts/:name` |
|
||||
| 인스타 | GET | `/api/insta/tasks/:task_id` |
|
||||
| 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` |
|
||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||
|
||||
1795
docs/superpowers/plans/2026-05-13-ai-news-sentiment-node.md
Normal file
1795
docs/superpowers/plans/2026-05-13-ai-news-sentiment-node.md
Normal file
File diff suppressed because it is too large
Load Diff
1826
docs/superpowers/plans/2026-05-13-screener-node-canvas.md
Normal file
1826
docs/superpowers/plans/2026-05-13-screener-node-canvas.md
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
858
docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
858
docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
@@ -0,0 +1,858 @@
|
||||
# Signal V2 Phase 1 — stock WebAI API 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:** Confidence Signal Pipeline V2 의 Phase 2 (web-ai pull worker) 가 polling 할 stock 의 인증된 입력 계약 3종 (`/api/webai/portfolio`, `/api/webai/news-sentiment`, X-WebAI-Key 인증 인프라) 을 신설.
|
||||
|
||||
**Architecture:** stock FastAPI app 에 `/api/webai/*` prefix 의 신규 endpoint 2개 추가. 인증은 `verify_webai_key` FastAPI dependency (단일 정적 키 `WEBAI_API_KEY` 환경변수 비교). nginx 에 `/api/webai/` location + `limit_req` rate limit. 기존 `/api/portfolio` 무변경, web-ui 영향 0.
|
||||
|
||||
**Tech Stack:** FastAPI / pytest + TestClient / sqlite3 / nginx (limit_req_zone)
|
||||
|
||||
**Spec:** `web-ui/docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
| 파일 | 책임 |
|
||||
|------|------|
|
||||
| `web-backend/stock/app/auth.py` (신규) | `verify_webai_key` FastAPI dependency — X-WebAI-Key 헤더 검증, env 미설정 503, 인증 실패 401 + logger.warning |
|
||||
| `web-backend/stock/app/main.py` (수정) | 2 신규 endpoint: `GET /api/webai/portfolio`, `GET /api/webai/news-sentiment`. 기존 `get_portfolio()` 응답 위에 pnl_pct augment mapper |
|
||||
| `web-backend/stock/app/test_webai_auth.py` (신규) | `verify_webai_key` 단위 3 케이스 |
|
||||
| `web-backend/stock/app/test_webai_endpoints.py` (신규) | 두 endpoint × 4 + 공통 4 = 12 통합 케이스 |
|
||||
| `web-backend/nginx/default.conf` (수정) | `limit_req_zone webai` + `/api/webai/` location |
|
||||
| `web-backend/docker-compose.yml` (수정) | stock 컨테이너 env 에 `WEBAI_API_KEY` 추가 |
|
||||
|
||||
---
|
||||
|
||||
## Task 순서
|
||||
|
||||
```
|
||||
Task 1: auth.py + verify_webai_key 단위 테스트 (TDD)
|
||||
Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스 (TDD)
|
||||
Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스 (TDD)
|
||||
Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date)
|
||||
Task 5: docker-compose env 추가
|
||||
Task 6: nginx config (rate limit + location + 헤더 forward)
|
||||
Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증
|
||||
```
|
||||
|
||||
각 Task 는 TDD 패턴 (test 먼저 → fail 확인 → 구현 → pass → commit).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: auth.py + verify_webai_key 단위 테스트
|
||||
|
||||
**Files:**
|
||||
- Create: `web-backend/stock/app/auth.py`
|
||||
- Create: `web-backend/stock/app/test_webai_auth.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `web-backend/stock/app/test_webai_auth.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def _make_request() -> Request:
|
||||
"""Minimal Request stub for verify_webai_key (only request.url.path + request.client used)."""
|
||||
scope = {
|
||||
"type": "http",
|
||||
"path": "/api/webai/test",
|
||||
"headers": [],
|
||||
"client": ("1.2.3.4", 12345),
|
||||
}
|
||||
return Request(scope=scope)
|
||||
|
||||
|
||||
def test_verify_with_valid_key_passes(monkeypatch):
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||
from app.auth import verify_webai_key
|
||||
verify_webai_key(_make_request(), x_webai_key="secret-key-abc")
|
||||
|
||||
|
||||
def test_verify_without_key_raises_401(monkeypatch):
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||
from app.auth import verify_webai_key
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_webai_key(_make_request(), x_webai_key=None)
|
||||
assert exc.value.status_code == 401
|
||||
assert "X-WebAI-Key" in exc.value.detail
|
||||
|
||||
|
||||
def test_verify_with_wrong_key_raises_401(monkeypatch):
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||
from app.auth import verify_webai_key
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_webai_key(_make_request(), x_webai_key="wrong-key")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_verify_returns_503_when_env_missing(monkeypatch):
|
||||
monkeypatch.delenv("WEBAI_API_KEY", raising=False)
|
||||
from app.auth import verify_webai_key
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_webai_key(_make_request(), x_webai_key="anything")
|
||||
assert exc.value.status_code == 503
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v`
|
||||
Expected: ImportError: cannot import name 'verify_webai_key' from 'app.auth' (또는 ModuleNotFoundError: No module named 'app.auth')
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `web-backend/stock/app/auth.py`:
|
||||
|
||||
```python
|
||||
import os
|
||||
import logging
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger("stock")
|
||||
|
||||
|
||||
def verify_webai_key(
|
||||
request: Request,
|
||||
x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"),
|
||||
) -> None:
|
||||
"""
|
||||
/api/webai/* 보호용 FastAPI dependency.
|
||||
|
||||
- WEBAI_API_KEY env 미설정 → 503 (다른 endpoint 무영향)
|
||||
- 헤더 누락 또는 키 불일치 → 401 + logger.warning(ip)
|
||||
"""
|
||||
configured = os.getenv("WEBAI_API_KEY", "").strip()
|
||||
if not configured:
|
||||
logger.error("WEBAI_API_KEY not configured — refusing /api/webai/* request")
|
||||
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||
|
||||
if not x_webai_key or x_webai_key != configured:
|
||||
remote = request.client.host if request.client else "?"
|
||||
logger.warning("auth_fail path=%s remote=%s", request.url.path, remote)
|
||||
raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v`
|
||||
Expected: 4 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-backend/stock/app/auth.py web-backend/stock/app/test_webai_auth.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock-webai): add X-WebAI-Key auth dependency + tests
|
||||
|
||||
verify_webai_key FastAPI dependency: 401 on missing/wrong key,
|
||||
503 when WEBAI_API_KEY env unset. 4 unit tests pass.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper)
|
||||
- Create: `web-backend/stock/app/test_webai_endpoints.py` (portfolio 4 케이스)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests (portfolio 4 케이스)**
|
||||
|
||||
Create `web-backend/stock/app/test_webai_endpoints.py`:
|
||||
|
||||
```python
|
||||
import os
|
||||
import sqlite3
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.screener.schema import ensure_screener_schema
|
||||
from app.db import init_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_db_and_auth(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "stock.db"
|
||||
# 기본 stock DB 스키마
|
||||
monkeypatch.setenv("STOCK_DB_PATH", str(db_path))
|
||||
init_db()
|
||||
# screener 스키마 (news_sentiment, krx_master 등)
|
||||
c = sqlite3.connect(db_path)
|
||||
ensure_screener_schema(c)
|
||||
c.close()
|
||||
# WEBAI_API_KEY 활성화
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
HEADERS_OK = {"X-WebAI-Key": "test-secret"}
|
||||
|
||||
|
||||
def _seed_portfolio(broker="키움", ticker="005930", name="삼성전자",
|
||||
quantity=100, avg_price=75000.0, purchase_price=75500.0):
|
||||
from app.db import add_portfolio_item
|
||||
return add_portfolio_item(broker, ticker, name, quantity, avg_price,
|
||||
purchase_price=purchase_price)
|
||||
|
||||
|
||||
def test_webai_portfolio_normal_response_includes_pnl_pct(client, monkeypatch):
|
||||
_seed_portfolio()
|
||||
|
||||
# current_price 모킹 — profit_rate 4.67% 만들기
|
||||
from app import main
|
||||
monkeypatch.setattr(
|
||||
main, "get_current_prices_detail",
|
||||
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "2026-05-15T15:30:00"}}
|
||||
)
|
||||
|
||||
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["holdings"]) == 1
|
||||
h = body["holdings"][0]
|
||||
assert h["pnl_pct"] is not None
|
||||
assert abs(h["pnl_pct"] - 0.0467) < 0.0005 # 0.0467 ± rounding
|
||||
|
||||
|
||||
def test_webai_portfolio_summary_has_total_pnl_pct(client, monkeypatch):
|
||||
_seed_portfolio()
|
||||
from app import main
|
||||
monkeypatch.setattr(
|
||||
main, "get_current_prices_detail",
|
||||
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
||||
)
|
||||
|
||||
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||
body = r.json()
|
||||
assert "total_pnl_pct" in body["summary"]
|
||||
assert abs(body["summary"]["total_pnl_pct"] - 0.0467) < 0.0005
|
||||
|
||||
|
||||
def test_webai_portfolio_pnl_pct_matches_profit_rate_divided_100(client, monkeypatch):
|
||||
_seed_portfolio()
|
||||
from app import main
|
||||
monkeypatch.setattr(
|
||||
main, "get_current_prices_detail",
|
||||
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
||||
)
|
||||
|
||||
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||
h = r.json()["holdings"][0]
|
||||
assert h["pnl_pct"] == round(h["profit_rate"] / 100, 6)
|
||||
|
||||
|
||||
def test_webai_portfolio_missing_key_returns_401(client):
|
||||
r = client.get("/api/webai/portfolio")
|
||||
assert r.status_code == 401
|
||||
assert "X-WebAI-Key" in r.json()["detail"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||
Expected: 4 failed with 404 (endpoint not defined yet) — except `missing_key_returns_401` 도 404 (endpoint 자체가 없으므로)
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Modify `web-backend/stock/app/main.py` — add right after the imports block (around line 27):
|
||||
|
||||
```python
|
||||
from .auth import verify_webai_key
|
||||
```
|
||||
|
||||
And add the new endpoint right after the existing `get_portfolio()` function (after line 384):
|
||||
|
||||
```python
|
||||
def _augment_portfolio_with_pnl_pct(raw: dict) -> dict:
|
||||
"""Add pnl_pct (ratio) to each holding and total_pnl_pct to summary."""
|
||||
holdings = []
|
||||
for h in raw["holdings"]:
|
||||
pnl_pct = round(h["profit_rate"] / 100, 6) if h.get("profit_rate") is not None else None
|
||||
holdings.append({**h, "pnl_pct": pnl_pct})
|
||||
|
||||
summary = dict(raw["summary"])
|
||||
rate = summary.get("total_profit_rate")
|
||||
summary["total_pnl_pct"] = round(rate / 100, 6) if rate is not None else 0.0
|
||||
|
||||
return {"holdings": holdings, "cash": raw["cash"], "summary": summary}
|
||||
|
||||
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||
Expected: 4 passed
|
||||
|
||||
Also run full stock suite to verify no regression:
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||
Expected: 86 + 4 = 90 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock-webai): /api/webai/portfolio + pnl_pct augment
|
||||
|
||||
Reuses get_portfolio() and adds pnl_pct (ratio, profit_rate/100) to
|
||||
each holding plus total_pnl_pct to summary. 4 integration tests pass.
|
||||
verify_webai_key dependency enforced.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper)
|
||||
- Modify: `web-backend/stock/app/test_webai_endpoints.py` (news-sentiment 4 케이스 추가)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests (news-sentiment 4 케이스)**
|
||||
|
||||
Append to `web-backend/stock/app/test_webai_endpoints.py`:
|
||||
|
||||
```python
|
||||
def _seed_news_sentiment(date_str: str, rows: list[tuple]):
|
||||
"""rows: list of (ticker, score_raw, reason, news_count)."""
|
||||
db_path = os.environ["STOCK_DB_PATH"]
|
||||
c = sqlite3.connect(db_path)
|
||||
for ticker, score, reason, news_count in rows:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO news_sentiment "
|
||||
"(ticker, date, score_raw, reason, news_count, source) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'articles')",
|
||||
(ticker, date_str, score, reason, news_count)
|
||||
)
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
|
||||
def _seed_krx_master(rows: list[tuple]):
|
||||
"""rows: list of (ticker, name)."""
|
||||
db_path = os.environ["STOCK_DB_PATH"]
|
||||
c = sqlite3.connect(db_path)
|
||||
import datetime as dt
|
||||
now = dt.datetime.utcnow().isoformat()
|
||||
for ticker, name in rows:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO krx_master "
|
||||
"(ticker, name, market, market_cap, updated_at) VALUES (?, ?, 'KOSPI', 0, ?)",
|
||||
(ticker, name, now)
|
||||
)
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
|
||||
def test_webai_news_sentiment_returns_latest_date_when_no_param(client):
|
||||
_seed_krx_master([("005930", "삼성전자"), ("000660", "SK하이닉스")])
|
||||
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "old", 5)])
|
||||
_seed_news_sentiment("2026-05-15", [
|
||||
("005930", 6.2, "HBM 양산 가시화", 12),
|
||||
("000660", 5.5, "PPI 우려에도 강세", 8),
|
||||
])
|
||||
|
||||
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["date"] == "2026-05-15"
|
||||
assert body["count"] == 2
|
||||
# sorted by score DESC
|
||||
assert body["items"][0]["ticker"] == "005930"
|
||||
assert body["items"][0]["score"] == 6.2
|
||||
assert body["items"][0]["name"] == "삼성전자"
|
||||
assert body["items"][0]["reason"] == "HBM 양산 가시화"
|
||||
|
||||
|
||||
def test_webai_news_sentiment_filters_by_date_param(client):
|
||||
_seed_krx_master([("005930", "삼성전자")])
|
||||
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "yesterday", 5)])
|
||||
_seed_news_sentiment("2026-05-15", [("005930", 6.2, "today", 12)])
|
||||
|
||||
r = client.get("/api/webai/news-sentiment?date=2026-05-14", headers=HEADERS_OK)
|
||||
body = r.json()
|
||||
assert body["date"] == "2026-05-14"
|
||||
assert body["count"] == 1
|
||||
assert body["items"][0]["reason"] == "yesterday"
|
||||
|
||||
|
||||
def test_webai_news_sentiment_empty_table_returns_count_zero(client):
|
||||
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||
body = r.json()
|
||||
assert body["date"] is None
|
||||
assert body["count"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
def test_webai_news_sentiment_items_sorted_by_score_desc(client):
|
||||
_seed_krx_master([("A", "A주"), ("B", "B주"), ("C", "C주")])
|
||||
_seed_news_sentiment("2026-05-15", [
|
||||
("A", 1.0, "low", 1),
|
||||
("B", 9.0, "high", 1),
|
||||
("C", 5.0, "mid", 1),
|
||||
])
|
||||
|
||||
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||
items = r.json()["items"]
|
||||
assert [i["score"] for i in items] == [9.0, 5.0, 1.0]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py::test_webai_news_sentiment_returns_latest_date_when_no_param app/test_webai_endpoints.py::test_webai_news_sentiment_filters_by_date_param app/test_webai_endpoints.py::test_webai_news_sentiment_empty_table_returns_count_zero app/test_webai_endpoints.py::test_webai_news_sentiment_items_sorted_by_score_desc -v`
|
||||
Expected: 4 failed with 404 (endpoint not defined)
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Modify `web-backend/stock/app/main.py` — add right after the portfolio endpoint added in Task 2:
|
||||
|
||||
```python
|
||||
def _fetch_news_sentiment_dump(date: str | None) -> dict:
|
||||
"""news_sentiment 일별 dump (krx_master JOIN, score DESC)."""
|
||||
from .db import _conn # _conn() is the shared connection helper
|
||||
conn = _conn()
|
||||
try:
|
||||
# 1) date resolve — None 이면 최신 date
|
||||
if date is None:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(date) FROM news_sentiment"
|
||||
).fetchone()
|
||||
date = row[0] if row and row[0] else None
|
||||
|
||||
if date is None:
|
||||
return {"date": None, "count": 0, "items": []}
|
||||
|
||||
# 2) JOIN krx_master.name (없으면 ticker 그대로)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT ns.ticker,
|
||||
COALESCE(km.name, ns.ticker) AS name,
|
||||
ns.score_raw,
|
||||
ns.reason,
|
||||
ns.news_count,
|
||||
ns.source
|
||||
FROM news_sentiment ns
|
||||
LEFT JOIN krx_master km ON km.ticker = ns.ticker
|
||||
WHERE ns.date = ?
|
||||
ORDER BY ns.score_raw DESC
|
||||
""",
|
||||
(date,)
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
items = [
|
||||
{"ticker": r[0], "name": r[1], "score": r[2],
|
||||
"reason": r[3], "news_count": r[4], "source": r[5]}
|
||||
for r in rows
|
||||
]
|
||||
return {"date": date, "count": len(items), "items": items}
|
||||
|
||||
|
||||
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_news_sentiment(date: str | None = None):
|
||||
"""web-ai 전용 news sentiment 일별 dump."""
|
||||
return _fetch_news_sentiment_dump(date)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||
Expected: 8 passed (4 portfolio + 4 news-sentiment)
|
||||
|
||||
Run full suite:
|
||||
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||
Expected: 86 + 8 = 94 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(stock-webai): /api/webai/news-sentiment daily dump
|
||||
|
||||
JOINs news_sentiment with krx_master for name fallback. Sorted by
|
||||
score DESC. Date param defaults to latest. Empty table returns
|
||||
{date: null, count: 0, items: []}. 4 integration tests pass.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/stock/app/test_webai_endpoints.py` (공통 4 케이스 추가)
|
||||
|
||||
- [ ] **Step 1: Write the tests**
|
||||
|
||||
Append to `web-backend/stock/app/test_webai_endpoints.py`:
|
||||
|
||||
```python
|
||||
def test_webai_401_response_has_no_payload_leak(client):
|
||||
"""인증 실패 응답에는 portfolio/sentiment 데이터가 없어야 한다."""
|
||||
_seed_portfolio()
|
||||
r = client.get("/api/webai/portfolio") # 헤더 없음
|
||||
assert r.status_code == 401
|
||||
body = r.json()
|
||||
assert "holdings" not in body
|
||||
assert "cash" not in body
|
||||
assert "summary" not in body
|
||||
|
||||
|
||||
def test_webai_503_when_env_missing(client, monkeypatch):
|
||||
"""WEBAI_API_KEY env 미설정 시 503, 다른 endpoint 영향 없음."""
|
||||
monkeypatch.delenv("WEBAI_API_KEY", raising=False)
|
||||
|
||||
r1 = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "anything"})
|
||||
assert r1.status_code == 503
|
||||
|
||||
# 기존 endpoint 무영향 — /api/portfolio 는 200 (빈 portfolio)
|
||||
r2 = client.get("/api/portfolio")
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
def test_webai_wrong_key_returns_401(client):
|
||||
r = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "wrong"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_webai_news_sentiment_unknown_date_returns_empty(client):
|
||||
r = client.get("/api/webai/news-sentiment?date=1999-01-01", headers=HEADERS_OK)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["count"] == 0
|
||||
assert body["items"] == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||
Expected: 12 passed (4 + 4 + 4)
|
||||
|
||||
Also run full stock suite:
|
||||
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||
Expected: 86 + 12 = 98 passed (note: spec said 101, but 86 stock + 4 auth + 12 endpoint = 102; the count in the spec was approximate, actual = current_baseline + 4 + 12)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-backend/stock/app/test_webai_endpoints.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(stock-webai): edge cases — 401 no leak, 503 env missing, unknown date
|
||||
|
||||
Verifies auth failure responses contain no portfolio/sentiment data,
|
||||
503 when WEBAI_API_KEY env unset (existing endpoints unaffected),
|
||||
news-sentiment unknown date returns empty result.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: docker-compose env 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/docker-compose.yml` (stock 서비스 env)
|
||||
|
||||
- [ ] **Step 1: Locate the stock environment block**
|
||||
|
||||
Run: `grep -n -A 20 "^ stock:" web-backend/docker-compose.yml | head -30`
|
||||
Expected: stock 서비스 블록 출력. environment 또는 env_file 항목 확인.
|
||||
|
||||
- [ ] **Step 2: Add WEBAI_API_KEY to stock env**
|
||||
|
||||
Edit `web-backend/docker-compose.yml` — find the `stock:` service block and add `WEBAI_API_KEY=${WEBAI_API_KEY}` line to the `environment:` list.
|
||||
|
||||
Example final state (excerpt):
|
||||
```yaml
|
||||
stock:
|
||||
container_name: stock
|
||||
build:
|
||||
context: ./stock
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- STOCK_DB_PATH=/app/data/stock.db
|
||||
- WEBAI_API_KEY=${WEBAI_API_KEY}
|
||||
# ... other vars
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify compose config**
|
||||
|
||||
Run: `cd web-backend && docker compose config | grep -A 30 "stock:" | grep WEBAI_API_KEY`
|
||||
Expected: `WEBAI_API_KEY: ""` (env 미설정 시 빈 문자열) 또는 실제 값
|
||||
|
||||
If the line is missing, re-check the edit.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd web-backend
|
||||
git add docker-compose.yml
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(stock-webai): pass WEBAI_API_KEY env to stock container
|
||||
|
||||
Required by /api/webai/* endpoints. Operator must set WEBAI_API_KEY
|
||||
in NAS /volume1/docker/webpage/.env before deploy.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: nginx config (rate limit + location + 헤더 forward)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-backend/nginx/default.conf`
|
||||
|
||||
- [ ] **Step 1: Add limit_req_zone to http {} block**
|
||||
|
||||
Edit `web-backend/nginx/default.conf` — find the existing `limit_req_zone` directive (or the top of `http {}` block / top of `server {}` context) and add:
|
||||
|
||||
```nginx
|
||||
# /api/webai/* rate limit — web-ai pull worker (default 60/min, burst 20)
|
||||
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||
```
|
||||
|
||||
Place it at the top of the http context (before any server blocks) or alongside existing limit_req_zone directives.
|
||||
|
||||
- [ ] **Step 2: Add /api/webai/ location block**
|
||||
|
||||
In the same file, find the existing `location /api/stock/` (or similar) block inside the relevant `server {}` and add the new location BEFORE it (to ensure prefix matching priority is explicit):
|
||||
|
||||
```nginx
|
||||
location /api/webai/ {
|
||||
limit_req zone=webai burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://stock:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Validate nginx config syntax**
|
||||
|
||||
Run: `cd web-backend && docker compose run --rm --no-deps frontend nginx -t -c /etc/nginx/conf.d/default.conf 2>&1 | tail -5`
|
||||
|
||||
If frontend image isn't built locally, use:
|
||||
Run: `docker run --rm -v "$(pwd)/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:alpine nginx -t 2>&1`
|
||||
Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`
|
||||
|
||||
If the test fails due to missing upstream resolution (`host not found in upstream "stock"`), that's expected outside the compose network — the syntax check is what matters here. Ignore upstream resolution errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd web-backend
|
||||
git add nginx/default.conf
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(nginx-webai): /api/webai/ location with rate limit + X-WebAI-Key forward
|
||||
|
||||
limit_req_zone webai:5m rate=60r/m, burst=20 nodelay, return 429 on
|
||||
limit hit. Proxies to stock:8000 with X-Real-IP, X-Forwarded-For,
|
||||
and X-WebAI-Key headers preserved.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증
|
||||
|
||||
**Files:**
|
||||
- 운영 `.env` (NAS `/volume1/docker/webpage/.env`) — 사용자 수동
|
||||
- web-ai `.env` (Windows PC) — 사용자 수동 (Phase 2 진입 시 사용, 본 Phase 에서 미사용 OK)
|
||||
|
||||
**This task requires user action (NAS SSH + push). The implementer should pause and request the user to perform these steps. Do NOT mark the task complete until the user reports smoke test results.**
|
||||
|
||||
- [ ] **Step 1: Generate WEBAI_API_KEY (사용자)**
|
||||
|
||||
Sample command for the user to run locally:
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
|
||||
Save the output. This is the `WEBAI_API_KEY` value.
|
||||
|
||||
- [ ] **Step 2: Update NAS .env (사용자)**
|
||||
|
||||
SSH to NAS:
|
||||
```bash
|
||||
ssh user@gahusb.synology.me
|
||||
sudo vi /volume1/docker/webpage/.env
|
||||
```
|
||||
|
||||
Add line:
|
||||
```
|
||||
WEBAI_API_KEY=<the key generated in Step 1>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Push web-backend (사용자)**
|
||||
|
||||
Locally:
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git push
|
||||
```
|
||||
Wait for Gitea webhook → deployer rsync + docker compose up.
|
||||
|
||||
If deployer DEPLOY_FAIL false alarm (known issue, see graduation experience):
|
||||
```bash
|
||||
ssh user@gahusb.synology.me
|
||||
cd /volume1/docker/webpage
|
||||
docker compose up -d --build stock frontend
|
||||
docker ps --format "{{.Names}}: {{.Status}}" | grep -E "stock|frontend"
|
||||
```
|
||||
Expected: both `healthy`.
|
||||
|
||||
- [ ] **Step 4: Manual smoke — auth success**
|
||||
|
||||
```bash
|
||||
export WEBAI_API_KEY=<the value>
|
||||
curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio | head -c 200
|
||||
```
|
||||
Expected: 200 JSON beginning with `{"holdings":[`. If portfolio empty, `{"holdings":[],"cash":[...`.
|
||||
|
||||
```bash
|
||||
curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" "https://gahusb.synology.me/api/webai/news-sentiment" | head -c 300
|
||||
```
|
||||
Expected: 200 JSON with `"date":` and `"items":` keys.
|
||||
|
||||
- [ ] **Step 5: Manual smoke — auth failure**
|
||||
|
||||
```bash
|
||||
curl -i -s https://gahusb.synology.me/api/webai/portfolio | head -5
|
||||
```
|
||||
Expected:
|
||||
```
|
||||
HTTP/1.1 401 Unauthorized
|
||||
...
|
||||
{"detail":"invalid or missing X-WebAI-Key"}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -i -s -H "X-WebAI-Key: wrong" https://gahusb.synology.me/api/webai/portfolio | head -5
|
||||
```
|
||||
Expected: 401 with same detail.
|
||||
|
||||
- [ ] **Step 6: Manual smoke — rate limit**
|
||||
|
||||
```bash
|
||||
for i in $(seq 1 120); do
|
||||
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||
-H "X-WebAI-Key: $WEBAI_API_KEY" \
|
||||
https://gahusb.synology.me/api/webai/portfolio
|
||||
done | sort | uniq -c
|
||||
```
|
||||
Expected: significant `200` count plus some `429` (rate limit triggered). Example:
|
||||
```
|
||||
85 200
|
||||
35 429
|
||||
```
|
||||
|
||||
If you see all 200 (no 429), rate limit may not be applied. Check nginx logs and config.
|
||||
|
||||
- [ ] **Step 7: Verify web-ui unchanged**
|
||||
|
||||
Open https://gahusb.synology.me/ in browser. Navigate to `/stock` page. Verify the portfolio list still loads correctly (no errors). This confirms `/api/portfolio` (legacy, no auth) is unaffected.
|
||||
|
||||
- [ ] **Step 8: Verify 503 fallback (optional, requires env removal + redeploy)**
|
||||
|
||||
This is optional and disruptive — only run if you want to verify the 503 fallback explicitly. Skip in normal deploys.
|
||||
|
||||
```bash
|
||||
ssh user@gahusb.synology.me
|
||||
cd /volume1/docker/webpage
|
||||
# Comment out WEBAI_API_KEY in .env temporarily
|
||||
sed -i 's/^WEBAI_API_KEY=/#WEBAI_API_KEY=/' .env
|
||||
docker compose up -d stock
|
||||
sleep 5
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -H "X-WebAI-Key: anything" https://gahusb.synology.me/api/webai/portfolio
|
||||
# Expected: 503
|
||||
# Restore:
|
||||
sed -i 's/^#WEBAI_API_KEY=/WEBAI_API_KEY=/' .env
|
||||
docker compose up -d stock
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Report results to user (운영 검증 게이트)**
|
||||
|
||||
Report to the user:
|
||||
- Step 4 (auth success): PASS / FAIL with details
|
||||
- Step 5 (auth failure): PASS / FAIL
|
||||
- Step 6 (rate limit): PASS (some 429 observed) / FAIL (all 200)
|
||||
- Step 7 (web-ui unchanged): PASS / FAIL
|
||||
|
||||
Only after the user confirms all PASS, mark Task 7 complete. If any FAIL, investigate before proceeding to Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (plan author runs this)
|
||||
|
||||
**1. Spec coverage:**
|
||||
|
||||
| Spec § | 요구사항 | Plan task |
|
||||
|--------|----------|----------|
|
||||
| §2 포함 ① portfolio + pnl_pct | Task 2 ✅ |
|
||||
| §2 포함 ② news-sentiment | Task 3 ✅ |
|
||||
| §2 포함 ③ X-WebAI-Key 인증 | Task 1 ✅ |
|
||||
| §2 포함 ④ nginx rate limit | Task 6 ✅ |
|
||||
| §2 포함 ⑤ 인증 실패 logger | Task 1 (logger.warning 호출 포함) ✅ |
|
||||
| §2 포함 ⑥ 15 테스트 (4 unit + 12 integration) | Task 1 (4) + Task 2 (4) + Task 3 (4) + Task 4 (4) = 16. Note: spec said 15, plan delivers 16 (4 auth + 4 portfolio + 4 sentiment + 4 common). Counted higher, no gap. ✅ |
|
||||
| §4.1 portfolio shape with pnl_pct | Task 2 Step 3 ✅ |
|
||||
| §4.2 news-sentiment shape | Task 3 Step 3 ✅ |
|
||||
| §4.3 401 leak free | Task 4 Step 1 (`test_webai_401_response_has_no_payload_leak`) ✅ |
|
||||
| §4.4 503 when env missing | Task 1 (unit) + Task 4 (integration) ✅ |
|
||||
| §5 auth.py implementation | Task 1 Step 3 ✅ |
|
||||
| §6 nginx config | Task 6 ✅ |
|
||||
| §10 DoD | Task 7 covers manual smoke + web-ui verification ✅ |
|
||||
|
||||
No gaps.
|
||||
|
||||
**2. Placeholder scan:** No "TBD" / "implement later" / vague descriptions found. Every step has executable code or commands.
|
||||
|
||||
**3. Type consistency:**
|
||||
- `verify_webai_key(request, x_webai_key)` signature consistent across Tasks 1, 2, 3 ✅
|
||||
- `_augment_portfolio_with_pnl_pct(raw)` defined in Task 2, no later reference (helper internal to main.py) ✅
|
||||
- `_fetch_news_sentiment_dump(date)` defined in Task 3, signature consistent ✅
|
||||
- `HEADERS_OK = {"X-WebAI-Key": "test-secret"}` defined in Task 2, reused in Tasks 3 and 4 ✅
|
||||
- `_seed_portfolio()` defined in Task 2, reused in Task 4 ✅
|
||||
- `_seed_news_sentiment()` / `_seed_krx_master()` defined in Task 3, consistent ✅
|
||||
- `WEBAI_API_KEY` env var name consistent across all tasks ✅
|
||||
|
||||
Plan passes self-review.
|
||||
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,402 @@
|
||||
# web-ai V1 → signal_v1 Rename 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:** `web-ai/` 루트의 모든 V1 자산 (main_server.py + modules/ + data/ + tests/ + 진입점 스크립트 + 문서 + 로그) 을 `web-ai/signal_v1/` 안으로 atomic mv 하고, web-ai 루트에 신규 가이드 (`CLAUDE.md`, `start.bat`) 추가. V2 (`signal_v2/`) 추가 전 신/구 격리.
|
||||
|
||||
**Architecture:** 단일 atomic commit (stock-lab → stock graduation 과 동일 패턴). `git mv` 로 history 보존, `load_dotenv()` 호출만 경로 명시. cwd 기반 V1 코드라 import 변경 0. Phase 6 deprecation 시 `rm -rf signal_v1/` 단순화.
|
||||
|
||||
**Tech Stack:** git mv / Python load_dotenv path 갱신 / pytest 회귀 확인
|
||||
|
||||
**Spec:** `web-ui/docs/superpowers/specs/2026-05-16-web-ai-v1-rename-to-signal-v1.md`
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조 (Task 2 후)
|
||||
|
||||
```
|
||||
web-ai/
|
||||
├── .env ← 그대로 (V1 + V2 공유)
|
||||
├── .gitignore ← 그대로
|
||||
├── CLAUDE.md ← 신규 (web-ai 레벨 가이드)
|
||||
├── start.bat ← 신규 (signal_v1 진입 wrapper)
|
||||
├── signal_v1/ ← 신규 디렉토리
|
||||
│ ├── CLAUDE.md ← 기존 V1 가이드 (mv)
|
||||
│ ├── KIS_SETUP.md
|
||||
│ ├── README.md
|
||||
│ ├── main_server.py ← load_dotenv 경로 명시 갱신
|
||||
│ ├── warmup_and_restart.py ← load_dotenv 경로 명시 갱신
|
||||
│ ├── watchlist_manager.py
|
||||
│ ├── backtester.py
|
||||
│ ├── backtest_runner.py
|
||||
│ ├── theme_manager.py
|
||||
│ ├── start.bat ← 사용 안 함 (cleanup 안 함, 향후)
|
||||
│ ├── modules/ ← 전체
|
||||
│ ├── data/ ← 전체 (runtime data 보존)
|
||||
│ ├── tests/ ← 전체
|
||||
│ └── (log/json 파일들)
|
||||
└── (signal_v2/ 는 Phase 2 spec)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Atomic refactor (사전 점검 + git mv + 신규 파일 + 검증 + commit)
|
||||
|
||||
**Files:**
|
||||
- Source repo: `C:\Users\jaeoh\Desktop\workspace\web-ai` (별도 Gitea repo: `ai-trade.git`, branch `main`)
|
||||
- Create: `web-ai/signal_v1/` (디렉토리)
|
||||
- Create: `web-ai/CLAUDE.md` (신규)
|
||||
- Create: `web-ai/start.bat` (신규)
|
||||
- Move (git mv): web-ai 루트의 모든 V1 자산 → signal_v1/
|
||||
- Modify: `web-ai/signal_v1/main_server.py` (load_dotenv 명시 경로)
|
||||
- Modify: `web-ai/signal_v1/warmup_and_restart.py` (load_dotenv 명시 경로)
|
||||
- (필요 시) Modify: `signal_v1/modules/config.py` 또는 다른 load_dotenv 위치
|
||||
|
||||
- [ ] **Step 1: 사전 — 자동매매 봇 정지 확인 + git status clean**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git status
|
||||
```
|
||||
Expected: `nothing to commit, working tree clean`. 만약 dirty 면 implementer 는 BLOCKED 보고. 사용자가 stash 또는 commit 처리.
|
||||
|
||||
또한: V1 자동매매 봇이 실행 중이면 mv 도중 파일 잠금 위험. PowerShell:
|
||||
```powershell
|
||||
Get-Process python -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime
|
||||
```
|
||||
실행 중 Python 프로세스 발견 시 사용자에게 종료 요청. (장외 시간대에 작업 가정.)
|
||||
|
||||
- [ ] **Step 2: 사전 grep — load_dotenv 호출 위치 파악**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
grep -rn "load_dotenv" --include="*.py" .
|
||||
```
|
||||
Expected: 1~3개 hit. 각 hit 의 파일 경로 기록 (Step 6 에서 갱신). 일반적으로 main_server.py, warmup_and_restart.py, modules/config.py 중 1~2곳에 있음.
|
||||
|
||||
만약 hit 0 이면 V1 이 `.env` 를 다른 방식 (예: pydantic-settings) 으로 로드. 코드 경로 추가 grep:
|
||||
```bash
|
||||
grep -rn "BaseSettings\|env_file\|\.env" --include="*.py" .
|
||||
```
|
||||
어느 방식이든 cwd 가 signal_v1/ 으로 바뀌면 `.env` 가 parent (`web-ai/.env`) 에 있다는 사실을 코드가 알아야 함.
|
||||
|
||||
- [ ] **Step 3: 사전 baseline — 현 pytest 통과 개수 측정**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
python -m pytest tests/unit -q 2>&1 | tail -3
|
||||
```
|
||||
Expected output 형태: `N passed in Xs` (또는 `N passed, M warnings ...`). N 값을 baseline 으로 기록 (Step 13 에서 비교).
|
||||
|
||||
만약 baseline 자체가 실패면 implementer 는 DONE_WITH_CONCERNS 보고 — 사용자 결정 (pre-existing failure 라면 무시하고 진행 가능).
|
||||
|
||||
- [ ] **Step 4: signal_v1 디렉토리 생성**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
mkdir signal_v1
|
||||
```
|
||||
Verify:
|
||||
```bash
|
||||
ls -d signal_v1
|
||||
```
|
||||
Expected: `signal_v1`
|
||||
|
||||
- [ ] **Step 5: git mv 실행 (V1 자산 모두)**
|
||||
|
||||
다음 항목을 모두 `signal_v1/` 안으로 이동. `git mv` 사용 (history 보존):
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
|
||||
# 진입점 + 스크립트
|
||||
git mv main_server.py signal_v1/
|
||||
git mv warmup_and_restart.py signal_v1/
|
||||
git mv watchlist_manager.py signal_v1/
|
||||
git mv backtester.py signal_v1/
|
||||
git mv backtest_runner.py signal_v1/
|
||||
git mv theme_manager.py signal_v1/
|
||||
git mv start.bat signal_v1/
|
||||
|
||||
# 문서 (현 V1 가이드)
|
||||
git mv CLAUDE.md signal_v1/
|
||||
git mv KIS_SETUP.md signal_v1/
|
||||
git mv README.md signal_v1/
|
||||
|
||||
# 디렉토리
|
||||
git mv modules signal_v1/
|
||||
git mv data signal_v1/
|
||||
git mv tests signal_v1/
|
||||
|
||||
# 로그 / IPC / 캐시
|
||||
git mv bot_ipc.json signal_v1/ 2>/dev/null || true
|
||||
git mv bot_output.log signal_v1/ 2>/dev/null || true
|
||||
git mv daily_launcher.log signal_v1/ 2>/dev/null || true
|
||||
git mv server.log signal_v1/ 2>/dev/null || true
|
||||
git mv telegram_bot.log signal_v1/ 2>/dev/null || true
|
||||
git mv warmup.log signal_v1/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
`__pycache__/` 는 gitignore 이므로 git mv 불가능. 단순 mv:
|
||||
```bash
|
||||
mv __pycache__ signal_v1/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
git status --short | head -30
|
||||
ls signal_v1/
|
||||
ls
|
||||
```
|
||||
Expected: `signal_v1/` 안에 모든 V1 자산이 있고, web-ai 루트에는 `.env`, `.gitignore`, `signal_v1/` 만 (still untracked: none yet for new files).
|
||||
|
||||
- [ ] **Step 6: load_dotenv 경로 명시 갱신**
|
||||
|
||||
Step 2 에서 식별한 각 `load_dotenv()` 호출을 명시 경로로 변경. 가장 빈도 높은 패턴 (main_server.py 의 시작 부분):
|
||||
|
||||
기존 (cwd 기준):
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
```
|
||||
|
||||
신규 (명시 경로, signal_v1 의 parent = web-ai 루트):
|
||||
```python
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# web-ai/.env (signal_v1/ 의 parent) 명시 로드
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
```
|
||||
|
||||
Step 2 에서 식별한 모든 위치에 동일 패턴 적용. 만약 V1 이 `BaseSettings` (pydantic) 사용 시:
|
||||
```python
|
||||
class Settings(BaseSettings):
|
||||
class Config:
|
||||
env_file = str(Path(__file__).parent.parent / ".env")
|
||||
```
|
||||
|
||||
만약 V1 이 그냥 `os.getenv(...)` 만 쓰고 어딘가에서 명시적으로 load 하지 않는다면 (uvicorn 이 시작 시 cwd 의 .env 를 자동 로드 시) — 시작 wrapper (`web-ai/start.bat`) 가 `cd signal_v1` 후 실행하면 cwd=signal_v1 → `.env` 못 찾음. 해결: Step 7 의 `start.bat` 에서 명시적으로 `cd /d "%~dp0"` (= web-ai 루트) 후 `python signal_v1/main_server.py` 실행.
|
||||
|
||||
근데 그러면 main_server.py 안의 다른 상대 경로 (`data/kis_token.json` 등) 가 cwd=web-ai 일 때 `web-ai/data/kis_token.json` 을 찾음 → 잘못된 경로.
|
||||
|
||||
**결정**: cwd 는 `signal_v1/` 으로 두고 `load_dotenv(Path(__file__).parent.parent / ".env")` 명시. 다른 상대 경로는 cwd=signal_v1 기준이라 `data/...` 그대로 작동.
|
||||
|
||||
각 갱신 후 git status:
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git status --short | head -10
|
||||
```
|
||||
Expected: signal_v1/main_server.py 등 modified 표시.
|
||||
|
||||
- [ ] **Step 7: 신규 파일 — web-ai/CLAUDE.md**
|
||||
|
||||
Create `web-ai/CLAUDE.md`:
|
||||
|
||||
```markdown
|
||||
# web-ai — Workspace 가이드
|
||||
|
||||
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti) 의 두 시그널 파이프라인 컨테이너.
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
| 경로 | 역할 | 상태 |
|
||||
|------|------|------|
|
||||
| `signal_v1/` | V1 자체 자동매매 시스템 (main_server.py + Trading Bot + Telegram Bot + LSTM + Ollama + KIS 자동주문) | 운영 중. Confidence Signal Pipeline V2 Phase 6 에서 deprecation 예정 |
|
||||
| `signal_v2/` | V2 신호 파이프라인 (stock pull worker + Chronos-2 + signal API client) | Phase 2 에서 신설 |
|
||||
| `.env` | V1 + V2 환경변수 공유 | KIS_*, TELEGRAM_*, STOCK_API_URL, WEBAI_API_KEY 등 |
|
||||
| `start.bat` | V1 진입 (signal_v1 디렉토리 안 main_server.py 실행) | V2 별도 start 스크립트는 signal_v2/start.bat |
|
||||
|
||||
## 운영 가이드
|
||||
|
||||
- V1 시작: `start.bat` 또는 `cd signal_v1 && python main_server.py`
|
||||
- V2 시작 (Phase 2 이후): `cd signal_v2 && python -m uvicorn main:app --port 8001`
|
||||
- 둘 다 동시 실행 가능 (포트 분리: V1=8000, V2=8001)
|
||||
|
||||
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||
|
||||
`web-ui/docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md` 참조.
|
||||
|
||||
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조.
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 신규 파일 — web-ai/start.bat**
|
||||
|
||||
Create `web-ai/start.bat`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
cd /d "%~dp0\signal_v1"
|
||||
python main_server.py
|
||||
```
|
||||
|
||||
- [ ] **Step 9: git add 신규 파일**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git add CLAUDE.md start.bat
|
||||
git add signal_v1/main_server.py signal_v1/warmup_and_restart.py # load_dotenv 갱신
|
||||
# 추가로 갱신한 다른 .py 파일이 있으면 모두 add
|
||||
```
|
||||
|
||||
git status 점검:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
Expected: 모든 git mv + 신규 + modify 변경이 staged 상태.
|
||||
|
||||
- [ ] **Step 10: 잔여 grep — `from web-ai` 같은 잘못된 import 0건 확인**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
grep -rn "from web-ai\|import web-ai" --include="*.py" signal_v1/
|
||||
```
|
||||
Expected: 0 lines.
|
||||
|
||||
또한 V1 코드 안에 hardcoded 절대 경로 (예: `C:\Users\jaeoh\Desktop\workspace\web-ai\data\...`) 검사:
|
||||
```bash
|
||||
grep -rn "web-ai.data\|web-ai/data\|web-ai\\\\data" --include="*.py" signal_v1/
|
||||
```
|
||||
Expected: 0 lines.
|
||||
|
||||
만약 hit 있으면 implementer 는 DONE_WITH_CONCERNS 보고, 사용자가 조정.
|
||||
|
||||
- [ ] **Step 11: signal_v1 안에서 pytest 자동 검증**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||
python -m pytest tests/unit -q 2>&1 | tail -5
|
||||
```
|
||||
Expected: Step 3 의 baseline 과 동일한 PASS 개수 (회귀 없음).
|
||||
|
||||
만약 import 오류 (`ModuleNotFoundError: No module named 'modules'`) 가 발생하면 conftest.py 가 sys.path 를 수정하지 않을 가능성. 확인:
|
||||
```bash
|
||||
cat tests/unit/conftest.py | head -20
|
||||
```
|
||||
필요 시 `sys.path.insert(0, str(Path(__file__).parent.parent.parent))` 추가. 단, 기존 conftest 가 cwd 기반이면 cwd=signal_v1 에서 작동해야 함.
|
||||
|
||||
만약 다른 failure 면 BLOCKED 보고 — 사용자 진단.
|
||||
|
||||
- [ ] **Step 12: 잠시 후 다시 git status — 추가 untracked 없는지 확인**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git status
|
||||
```
|
||||
Expected: 모든 변경이 staged. 만약 새 untracked (pytest cache 등) 있으면 .gitignore 패턴 또는 무시.
|
||||
|
||||
- [ ] **Step 13: 단일 commit**
|
||||
|
||||
```bash
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor: web-ai V1 assets → signal_v1/ (graduation prep)
|
||||
|
||||
Atomic mv of root V1 assets (main_server.py + modules/ + data/ +
|
||||
tests/ + entry scripts + docs + logs) into signal_v1/ subdirectory.
|
||||
load_dotenv() updated to load web-ai/.env explicitly via Path.
|
||||
|
||||
Adds web-ai/CLAUDE.md (workspace guide) and web-ai/start.bat
|
||||
(signal_v1 entry wrapper). Prepares for signal_v2/ Phase 2.
|
||||
|
||||
Tests: signal_v1/tests/unit baseline preserved (no regression).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
git log -1 --stat
|
||||
```
|
||||
Expected: 1 commit, 다수 rename + 2 신규 (CLAUDE.md / start.bat) + 1-3 modified (load_dotenv 위치).
|
||||
|
||||
## Reporting
|
||||
|
||||
When done, report:
|
||||
- DONE: commit SHA, baseline test count (Step 3) + post-mv count (Step 11), 자동 grep 결과 (0 lines).
|
||||
- DONE_WITH_CONCERNS: implementation 됐지만 hardcoded path / pre-existing test fail 등 발견 — 상세 보고.
|
||||
- NEEDS_CONTEXT: load_dotenv 패턴이 spec 예상과 다름, 또는 conftest 추가 fix 필요 등.
|
||||
- BLOCKED: working tree dirty / pytest baseline 자체 실패 / git mv 충돌.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 사용자 수동 — 운영 검증 + push
|
||||
|
||||
**This task requires user action. Pause and request user to perform.**
|
||||
|
||||
- [ ] **Step 1: V1 자동매매 봇 정상 시작 검증**
|
||||
|
||||
사용자가 PowerShell 에서:
|
||||
```powershell
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ai
|
||||
.\start.bat
|
||||
```
|
||||
|
||||
기대 출력 (수십 줄):
|
||||
- `Config.validate()` 성공 (환경변수 누락 없음)
|
||||
- KIS OAuth `access_token` 발급 (또는 cached token 로드)
|
||||
- Telegram Bot started + `Conflict` 없음
|
||||
- ProcessWatchdog 시작
|
||||
- Uvicorn 0.0.0.0:8000 listening
|
||||
- 봇 사이클 (장중이면) 또는 idle (장외)
|
||||
|
||||
만약 `FileNotFoundError: .env` 또는 KIS auth 실패 시 — load_dotenv 경로 오류. Task 1 으로 돌아가 Step 6 조정.
|
||||
|
||||
- [ ] **Step 2: Telegram /status 명령 응답 검증**
|
||||
|
||||
사용자가 텔레그램에서 `/status` 명령. 봇이 정상 응답하면 IPC + SharedMemory + Telegram Bot 모두 정상.
|
||||
|
||||
- [ ] **Step 3: 30분 관측**
|
||||
|
||||
콘솔 또는 telegram_bot.log 에 에러 없음 + Watchdog 30초 간격 health check PASS 확인.
|
||||
|
||||
만약 자식 프로세스 (Trading Bot / Telegram Bot) 가 자동 종료 → restart loop → 재실패 시 Task 1 으로 돌아가 진단.
|
||||
|
||||
- [ ] **Step 4: git push (사용자, Gitea 자격증명)**
|
||||
|
||||
```powershell
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ai
|
||||
git push
|
||||
```
|
||||
|
||||
만약 자격증명 실패 시 사용자가 수동으로 처리 (메모리 `feedback_nas_deploy_paths.md` 의 Gitea 자격증명 패턴).
|
||||
|
||||
- [ ] **Step 5: 결과 보고 (사용자 → 컨트롤러)**
|
||||
|
||||
- Step 1 (start.bat 시작): PASS / FAIL — 첫 에러 메시지 공유
|
||||
- Step 2 (/status 응답): PASS / FAIL
|
||||
- Step 3 (30분 관측): PASS (no errors) / FAIL — 관측된 에러
|
||||
- Step 4 (push): PASS / FAIL
|
||||
|
||||
전부 PASS 시 Task 2 완료 → Phase 2 brainstorming 재개 (이미 6 결정 + 디자인 섹션 1-2 OK).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
|
||||
| Spec § | 요구사항 | Plan task |
|
||||
|--------|----------|----------|
|
||||
| §2 포함 (V1 자산 mv) | Task 1 Step 5 ✅ |
|
||||
| §2 포함 (web-ai/CLAUDE.md 신규) | Task 1 Step 7 ✅ |
|
||||
| §2 포함 (web-ai/start.bat 신규) | Task 1 Step 8 ✅ |
|
||||
| §2 범위 외 (Python import 변경 없음) | Task 1 Step 10 의 grep 으로 검증 ✅ |
|
||||
| §3.3 web-ai/CLAUDE.md 정확한 내용 | Task 1 Step 7 — 동일 markdown 본문 포함 ✅ |
|
||||
| §3.3 web-ai/start.bat 정확한 내용 | Task 1 Step 8 — 동일 bat 본문 포함 ✅ |
|
||||
| §3.4 load_dotenv 경로 갱신 | Task 1 Step 2 (grep) + Step 6 (갱신) ✅ |
|
||||
| §4 작업 순서 (사전 검토 → mv → 검증 → push → 사용자 검증) | Task 1 Step 1-13 + Task 2 ✅ |
|
||||
| §5 위험 (.env 로드 실패, 자동매매 중단 등) | Task 2 Step 1 의 first-start verification + load_dotenv 명시 ✅ |
|
||||
| §6.1 자동 검증 (pytest + grep) | Task 1 Step 3 (baseline) + Step 11 (post-mv) + Step 10 (grep) ✅ |
|
||||
| §6.2 수동 검증 (start.bat + /status + 30분 관측) | Task 2 Step 1-3 ✅ |
|
||||
| §8 DoD 8 항목 | 전체 (Task 1 + Task 2 합) ✅ |
|
||||
|
||||
No gaps.
|
||||
|
||||
**2. Placeholder scan:** No "TBD" / "implement later". load_dotenv 갱신은 Step 2 grep 결과에 의존하지만, Step 6 에 정확한 갱신 패턴 (2 코드 예시) 포함 — placeholder 아님.
|
||||
|
||||
**3. Type consistency:** N/A (refactor only, 새 함수/타입 0). 모든 step 의 명령어와 파일 경로 일관 — `signal_v1/` 표기 + `web-ai/` 표기 통일.
|
||||
|
||||
Plan passes self-review.
|
||||
@@ -0,0 +1,558 @@
|
||||
# AI News Sentiment Node — Design
|
||||
|
||||
**작성일**: 2026-05-13
|
||||
**작성자**: gahusb
|
||||
**상태**: Approved for implementation
|
||||
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — AI 뉴스 호재/악재 노드 후속 슬라이스)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
스크리너의 8번째 점수 노드 `AiNewsSentiment` 를 추가한다. 평일 **08:00 KST** 에 시총 상위 100종목의 네이버 종목 뉴스를 스크래핑하고 Claude Haiku로 호재/악재를 정량화하여, 그날의 sentiment를 (a) 텔레그램으로 호재/악재 Top 5 알림으로 전달하고, (b) 16:30 스크리너 자동 잡의 가중합에 percentile_rank 형태로 기여한다.
|
||||
|
||||
**Why**: 기존 7개 점수 노드는 모두 수치 기반(가격/거래량/외국인 수급)이며, 시장 정서(뉴스 호재/악재)는 반영되지 않는다. 트레이더 의사결정에 큰 영향을 주는 호재/악재 시그널을 LLM으로 정량화하면 정량 노드와 정성 노드를 한 점수 체계로 통합할 수 있다. 장 시작 전 알림으로 즉시 가치 전달.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
**포함 (이번 슬라이스)**:
|
||||
- 평일 08:00 KST agent-office cron → stock-lab `/snapshot/refresh-news-sentiment` 호출
|
||||
- 시총 상위 100종목 × 네이버 종목 뉴스 (`/item/news_news.naver?code=XXX`) 스크래핑
|
||||
- 종목당 Claude Haiku 1콜 (총 100콜 asyncio.gather 병렬, 동시성 10)
|
||||
- `news_sentiment(ticker, date, score_raw, reason, news_count, tokens_input, tokens_output, model, created_at)` 테이블
|
||||
- 8번째 ScoreNode `AiNewsSentiment` 등록 (registry, schema, ScreenContext, 가중합 통합)
|
||||
- 호재 Top 5 + 악재 Top 5 텔레그램 메시지 (장 시작 전 발송)
|
||||
- 프론트 캔버스 모드에 8번째 노드 추가 (`SCORE_KEYS` 한 줄 + `INITIAL_NODE_POSITIONS` 좌표 한 줄)
|
||||
|
||||
**범위 외 (NOT)**:
|
||||
- 뉴스 URL 단위 캐싱 (비용이 충분히 낮음)
|
||||
- 16:00 추가 cron (MVP 일 1회)
|
||||
- 시장 전체 뉴스 종목 매핑 LLM (시총 상위 100 명시적 매핑)
|
||||
- 백테스트 (sentiment 점수가 실수익에 미친 영향) — 별도 후속 슬라이스
|
||||
- 가중치 자동 조정 — spec §14 별도 슬라이스
|
||||
- 종목별 sentiment 트렌드 차트 — 데이터 누적 후 후속 슬라이스
|
||||
- 종목 5-10위 외 sentiment 가시화 — Top 5 알림 외 별도 대시보드 없음
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처 개요
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
[08:00 KST 평일] │ agent-office cron │
|
||||
│ on_ai_news_schedule() │
|
||||
└──────────────┬──────────────┘
|
||||
│ HTTP POST
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ stock-lab: /api/stock/screener/snapshot/ │
|
||||
│ refresh-news-sentiment │
|
||||
│ │
|
||||
│ ai_news/pipeline.refresh_daily(asof): │
|
||||
│ 1. krx_master 시총 상위 100 ticker 조회 │
|
||||
│ 2. asyncio.gather(Semaphore=10) 100 종목 병렬: │
|
||||
│ a. scraper.fetch_news(ticker) │
|
||||
│ b. analyzer.score_sentiment(ticker, news[]) │
|
||||
│ c. → {score: float, reason: str, ...} │
|
||||
│ 3. news_sentiment 일괄 upsert │
|
||||
│ 4. Top 5 호재/악재 추출 → 텔레그램 페이로드 빌드 │
|
||||
│ 5. agent-office /telegram/send 호출 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
[16:30 KST 평일] ┌─────────────────────────────┐
|
||||
│ agent-office on_screener_ │
|
||||
│ schedule (변경 없음) │
|
||||
└──────────────┬──────────────┘
|
||||
│ HTTP POST
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ stock-lab: /api/stock/screener/run mode=auto │
|
||||
│ │
|
||||
│ Screener.run(ctx): │
|
||||
│ ctx.news_sentiment = SELECT * FROM news_sentiment │
|
||||
│ WHERE date = asof │
|
||||
│ AiNewsSentiment.compute(ctx, params) │
|
||||
│ → percentile_rank(score_raw) for 100 tickers │
|
||||
│ → 가중합에 ai_news weight × percentile 점수 기여 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**의존성 추가**: `anthropic` Python SDK (stock-lab requirements.txt). `ANTHROPIC_API_KEY` 는 docker-compose.yml에 이미 stock-lab 환경변수로 존재.
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 분해 (신규 파일)
|
||||
|
||||
### 4.1 stock-lab
|
||||
|
||||
```
|
||||
web-backend/stock-lab/app/
|
||||
screener/
|
||||
ai_news/ ← 신규 모듈
|
||||
__init__.py
|
||||
scraper.py ← 네이버 finance 종목 뉴스 스크래핑
|
||||
analyzer.py ← Claude Haiku 호재/악재 분석
|
||||
pipeline.py ← refresh_daily() 메인 (스크래핑+병렬 LLM+DB upsert)
|
||||
telegram.py ← Top 5/Top 5 메시지 빌더
|
||||
nodes/
|
||||
ai_news.py ← 8번째 ScoreNode 클래스
|
||||
schema.py ← (수정) news_sentiment 테이블 DDL 추가
|
||||
registry.py ← (수정) NODE_REGISTRY["ai_news"] 등록
|
||||
engine.py ← (수정) ScreenContext에 news_sentiment 로딩
|
||||
router.py ← (수정) POST /snapshot/refresh-news-sentiment 라우트 추가
|
||||
requirements.txt ← (수정) anthropic 추가
|
||||
tests/
|
||||
test_ai_news_scraper.py ← 네이버 HTML mock 파싱
|
||||
test_ai_news_analyzer.py ← anthropic mock 응답
|
||||
test_ai_news_pipeline.py ← 5종목 mini integration
|
||||
test_ai_news_node.py ← percentile_rank + min_news_count 필터
|
||||
```
|
||||
|
||||
### 4.2 agent-office
|
||||
|
||||
```
|
||||
web-backend/agent-office/app/
|
||||
agents/stock.py ← (수정) on_ai_news_schedule 메서드 추가
|
||||
scheduler.py ← (수정) cron mon-fri 08:00 등록
|
||||
service_proxy.py ← (수정) refresh_ai_news_sentiment() helper 추가
|
||||
```
|
||||
|
||||
### 4.3 frontend
|
||||
|
||||
```
|
||||
web-ui/src/pages/stock/screener/
|
||||
components/canvas/constants/
|
||||
canvasLayout.js ← (수정) AI 노드 추가 (NODE_IDS / NAME_MAP / LABEL / POSITIONS / SCORE_KEYS)
|
||||
canvasLayout.test.js ← (수정) 카운트 8 점수 노드, 18 엣지로 갱신
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DB 스키마 (1개 신규 테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS news_sentiment (
|
||||
ticker TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- YYYY-MM-DD
|
||||
score_raw REAL NOT NULL, -- LLM 원점수 -10 ~ +10
|
||||
reason TEXT NOT NULL DEFAULT '', -- LLM 한 줄 근거
|
||||
news_count INTEGER NOT NULL DEFAULT 0, -- 분석에 사용된 뉴스 수
|
||||
tokens_input INTEGER NOT NULL DEFAULT 0, -- 비용 모니터링
|
||||
tokens_output INTEGER NOT NULL DEFAULT 0,
|
||||
model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
PRIMARY KEY (ticker, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_sentiment_date ON news_sentiment(date DESC);
|
||||
```
|
||||
|
||||
`schema.py` 의 `ensure_screener_schema(conn)` 함수에 이 DDL 추가. WAL + busy_timeout 패턴은 stock-lab `_conn()` 표준화로 이미 적용됨.
|
||||
|
||||
**기본 가중치 시드**: `DEFAULT_WEIGHTS["ai_news"] = 0.5` 추가 (다른 7노드의 default와 동일). 기존 settings 행이 있는 환경에서는 마이그레이션 1회 — `ensure_screener_schema()` 가 settings의 weights_json에 ai_news 키 누락 시 0.5로 보충하는 1회성 patch 적용.
|
||||
|
||||
---
|
||||
|
||||
## 6. ScoreNode 구현
|
||||
|
||||
```python
|
||||
# stock-lab/app/screener/nodes/ai_news.py
|
||||
import pandas as pd
|
||||
from .base import ScoreNode, percentile_rank
|
||||
|
||||
class AiNewsSentiment(ScoreNode):
|
||||
name = "ai_news"
|
||||
label = "AI 뉴스 호재/악재"
|
||||
default_params = {"min_news_count": 1}
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_news_count": {
|
||||
"type": "integer", "default": 1, "minimum": 0,
|
||||
"description": "최소 분석 뉴스 수. 미만이면 NaN.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def compute(self, ctx, params):
|
||||
df = getattr(ctx, "news_sentiment", None)
|
||||
if df is None or df.empty:
|
||||
return pd.Series(dtype=float)
|
||||
df = df[df["news_count"] >= params["min_news_count"]]
|
||||
if df.empty:
|
||||
return pd.Series(dtype=float)
|
||||
return percentile_rank(df.set_index("ticker")["score_raw"])
|
||||
```
|
||||
|
||||
`ScreenContext` dataclass에 `news_sentiment: Optional[pd.DataFrame] = None` 필드 추가 (default None 으로 기존 호출자 호환성 유지). `ScreenContext.load(conn, asof)` 에 로딩 한 줄 추가:
|
||||
|
||||
```python
|
||||
news_sentiment = pd.read_sql_query(
|
||||
"SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
|
||||
conn, params=(asof.isoformat(),),
|
||||
)
|
||||
return ScreenContext(..., news_sentiment=news_sentiment)
|
||||
```
|
||||
|
||||
기존 테스트 fixture에서 `ScreenContext(...)` 를 직접 생성하는 케이스는 default=None 으로 자동 호환. AiNewsSentiment.compute() 는 `getattr(ctx, "news_sentiment", None)` 로 안전 fallback.
|
||||
|
||||
---
|
||||
|
||||
## 7. 파이프라인 (`ai_news/pipeline.py`)
|
||||
|
||||
```python
|
||||
async def refresh_daily(conn, asof, *, tickers=None, model=DEFAULT_MODEL,
|
||||
concurrency=10, news_per_ticker=5):
|
||||
"""
|
||||
Returns:
|
||||
{"asof": ..., "updated": N, "failures": [...], "duration_sec": ...,
|
||||
"tokens_input": ..., "tokens_output": ..., "top_pos": [...], "top_neg": [...]}
|
||||
"""
|
||||
if tickers is None:
|
||||
tickers = _top_market_cap_tickers(conn, n=100)
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
async with httpx.AsyncClient(...) as http_client, AsyncAnthropic(...) as llm:
|
||||
tasks = [_process_ticker(t, sem, http_client, llm, news_per_ticker, model)
|
||||
for t in tickers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
successes = [r for r in results if isinstance(r, dict)]
|
||||
failures = [r for r in results if isinstance(r, BaseException)]
|
||||
|
||||
_upsert_news_sentiment(conn, asof, successes)
|
||||
|
||||
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
||||
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
|
||||
|
||||
return {
|
||||
"asof": asof.isoformat(),
|
||||
"updated": len(successes),
|
||||
"failures": [str(e) for e in failures],
|
||||
"duration_sec": ...,
|
||||
"tokens_input": sum(r["tokens_input"] for r in successes),
|
||||
"tokens_output": sum(r["tokens_output"] for r in successes),
|
||||
"top_pos": top_pos,
|
||||
"top_neg": top_neg,
|
||||
}
|
||||
|
||||
|
||||
async def _process_ticker(ticker, sem, http_client, llm, news_per_ticker, model):
|
||||
async with sem:
|
||||
await asyncio.sleep(0.2) # rate limit
|
||||
news = await scraper.fetch_news(http_client, ticker, n=news_per_ticker)
|
||||
if not news:
|
||||
return {"ticker": ticker, "score_raw": 0.0,
|
||||
"reason": "no news", "news_count": 0,
|
||||
"tokens_input": 0, "tokens_output": 0}
|
||||
return await analyzer.score_sentiment(llm, ticker, news, model=model)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Scraper (`ai_news/scraper.py`)
|
||||
|
||||
```python
|
||||
NAVER_NEWS_URL = "https://finance.naver.com/item/news_news.naver"
|
||||
|
||||
async def fetch_news(client, ticker, n=5):
|
||||
r = await client.get(NAVER_NEWS_URL, params={"code": ticker, "page": 1})
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
rows = soup.select("table.type5 tbody tr")[:n]
|
||||
out = []
|
||||
for row in rows:
|
||||
title_el = row.select_one("td.title a")
|
||||
date_el = row.select_one("td.date")
|
||||
if not title_el or not date_el:
|
||||
continue
|
||||
out.append({
|
||||
"title": title_el.get_text(strip=True),
|
||||
"date": date_el.get_text(strip=True),
|
||||
})
|
||||
return out
|
||||
```
|
||||
|
||||
Rate limit: pipeline 의 `Semaphore(10) + 0.2초 sleep` 으로 보호.
|
||||
|
||||
---
|
||||
|
||||
## 9. Analyzer (`ai_news/analyzer.py`)
|
||||
|
||||
```python
|
||||
DEFAULT_MODEL = os.getenv("AI_NEWS_MODEL", "claude-haiku-4-5-20251001")
|
||||
|
||||
PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {n}개의 헤드라인입니다.
|
||||
|
||||
{news_block}
|
||||
|
||||
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
|
||||
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
|
||||
reason: 30자 이내 한 줄 근거.
|
||||
|
||||
JSON으로만 응답하세요:
|
||||
{{"score": <float>, "reason": "<string>"}}"""
|
||||
|
||||
async def score_sentiment(llm, ticker, news, *, model=DEFAULT_MODEL, name=None):
|
||||
news_block = "\n".join(f"- {n['title']}" for n in news)
|
||||
prompt = PROMPT_TEMPLATE.format(
|
||||
name=name or ticker, ticker=ticker,
|
||||
n=len(news), news_block=news_block,
|
||||
)
|
||||
resp = await llm.messages.create(
|
||||
model=model, max_tokens=200,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
try:
|
||||
text = resp.content[0].text
|
||||
data = json.loads(text)
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"score_raw": float(data["score"]),
|
||||
"reason": str(data["reason"])[:200],
|
||||
"news_count": len(news),
|
||||
"tokens_input": resp.usage.input_tokens,
|
||||
"tokens_output": resp.usage.output_tokens,
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
log.warning("ai_news parse fail for %s: %s", ticker, e)
|
||||
return {
|
||||
"ticker": ticker, "score_raw": 0.0,
|
||||
"reason": f"parse fail: {e!s}",
|
||||
"news_count": len(news),
|
||||
"tokens_input": resp.usage.input_tokens,
|
||||
"tokens_output": resp.usage.output_tokens,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 텔레그램 메시지 (`ai_news/telegram.py`)
|
||||
|
||||
```python
|
||||
def build_telegram_payload(*, asof, top_pos, top_neg,
|
||||
tokens_input, tokens_output, model):
|
||||
cost_won = int(tokens_input * 0.0013 + tokens_output * 0.0065) # ₩ 환산
|
||||
lines = [
|
||||
f"🌅 *AI 뉴스 분석* ({asof} 08:00)",
|
||||
"",
|
||||
"📈 *호재 Top 5*",
|
||||
]
|
||||
for i, r in enumerate(top_pos, 1):
|
||||
lines.append(
|
||||
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||
f"{_escape(r['reason'])}"
|
||||
)
|
||||
lines += ["", "📉 *악재 Top 5*"]
|
||||
for i, r in enumerate(top_neg, 1):
|
||||
lines.append(
|
||||
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
|
||||
f"{_escape(r['reason'])}"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
|
||||
f"약 ₩{cost_won:,}_",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
agent-office 가 텔레그램 발송 책임: stock-lab `/refresh-news-sentiment` 응답을 받아 `messaging.send_raw(text, parse_mode="MarkdownV2")` 호출.
|
||||
|
||||
---
|
||||
|
||||
## 11. agent-office 통합
|
||||
|
||||
### 11.1 `agents/stock.py`
|
||||
|
||||
```python
|
||||
async def on_ai_news_schedule(self):
|
||||
"""평일 08:00 KST cron."""
|
||||
try:
|
||||
result = await service_proxy.refresh_ai_news_sentiment()
|
||||
except httpx.HTTPError as e:
|
||||
await self.telegram.send_raw(f"⚠️ AI 뉴스 분석 실패: {e!s}")
|
||||
return
|
||||
|
||||
if result.get("updated", 0) == 0:
|
||||
await self.telegram.send_raw("⚠️ AI 뉴스: 0종목 분석됨 (스크래핑/LLM 전체 실패)")
|
||||
return
|
||||
|
||||
failure_rate = len(result.get("failures", [])) / 100
|
||||
if failure_rate > 0.3:
|
||||
await self.telegram.send_raw(
|
||||
f"⚠️ AI 뉴스 실패율 {failure_rate:.0%} — 어제 데이터 사용 가능성"
|
||||
)
|
||||
|
||||
payload = build_telegram_payload(
|
||||
asof=result["asof"],
|
||||
top_pos=result["top_pos"], top_neg=result["top_neg"],
|
||||
tokens_input=result["tokens_input"],
|
||||
tokens_output=result["tokens_output"],
|
||||
model=DEFAULT_MODEL,
|
||||
)
|
||||
await self.telegram.send_raw(payload, parse_mode="MarkdownV2")
|
||||
```
|
||||
|
||||
### 11.2 `scheduler.py`
|
||||
|
||||
```python
|
||||
scheduler.add_job(
|
||||
stock_agent.on_ai_news_schedule,
|
||||
"cron", day_of_week="mon-fri", hour=8, minute=0,
|
||||
id="stock_ai_news_sentiment",
|
||||
timezone="Asia/Seoul",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 에러 처리
|
||||
|
||||
| 상황 | 처리 |
|
||||
|------|------|
|
||||
| 네이버 뉴스 페이지 404/타임아웃 | 해당 종목 score_raw=0 + reason="no news", failures 별도 카운트 |
|
||||
| BeautifulSoup 파싱 실패 (HTML 구조 변경) | 동일 처리 (failures 카운트) |
|
||||
| LLM JSON 파싱 실패 | score_raw=0 + reason="parse fail", tokens는 그래도 누적 (실제 호출됨) |
|
||||
| anthropic API 5xx | 자동 retry 1회 (SDK 기본), 실패 시 failures 카운트 |
|
||||
| 전체 cron 실패 (네트워크 등) | agent-office 에러 텔레그램 + 16:30 잡은 어제 sentiment 데이터 사용 (date 비교로 자동) |
|
||||
| 실패율 > 30% | 텔레그램 경고 알림. 단 부분 데이터는 그대로 DB 반영 |
|
||||
| 16:30 시점 news_sentiment 비어 있음 | AiNewsSentiment.compute() 가 빈 Series 반환 → 가중합에서 이 노드 자동 제외 |
|
||||
| LLM이 -10/+10 범위 벗어난 값 응답 | clamp `max(-10, min(10, score))` 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 동시성 & rate limit
|
||||
|
||||
- `asyncio.Semaphore(10)` — 동시 10종목 처리 (네이버 차단 회피)
|
||||
- 종목 처리 사이 0.2초 sleep (semaphore 안에서)
|
||||
- 100종목 ÷ 10 동시 × 평균 3초/종목 = **~30-60초 총 소요**
|
||||
- agent-office httpx timeout = 180초 (충분한 여유)
|
||||
- stock-lab _conn() 의 WAL + busy_timeout=120s 로 16:30 잡과 동시 실행 시 lock 보호
|
||||
|
||||
---
|
||||
|
||||
## 14. 비용 모니터링
|
||||
|
||||
- 종목당 평균: input ~500 tokens, output ~50 tokens
|
||||
- 일 비용: 50K input × $1/M + 5K output × $5/M = **$0.075/일**
|
||||
- 월 비용: **~$1.6** (텔레그램 메시지 하단에 매일 ₩72 형태로 표시)
|
||||
- `news_sentiment.tokens_input/output` 컬럼으로 누적 추적 가능
|
||||
- 환산: 1 USD ≈ ₩1,300, input $0.0013/K, output $0.0065/K (장기 평균)
|
||||
|
||||
---
|
||||
|
||||
## 15. 프론트엔드 변경
|
||||
|
||||
캔버스 모드에 8번째 점수 노드 추가. 아래 한 파일만 수정:
|
||||
|
||||
```js
|
||||
// canvasLayout.js
|
||||
export const NODE_IDS = {
|
||||
...,
|
||||
AI_NEWS: 'score-ai-news', // 신규
|
||||
...,
|
||||
};
|
||||
export const NODE_KIND_MAP = { ..., [NODE_IDS.AI_NEWS]: 'score', ... };
|
||||
export const SCORE_NODE_NAME_MAP = { ..., [NODE_IDS.AI_NEWS]: 'ai_news' };
|
||||
export const SCORE_NODE_LABEL = {
|
||||
...,
|
||||
[NODE_IDS.AI_NEWS]: { icon: '🤖', title: 'AI 뉴스' },
|
||||
};
|
||||
export const INITIAL_NODE_POSITIONS = {
|
||||
...,
|
||||
// 기존 7개 score y: 0,90,180,270,360,450,540 → 8개 y: 0,90,...,630
|
||||
[NODE_IDS.AI_NEWS]: { x: 480, y: 630 },
|
||||
};
|
||||
const SCORE_KEYS = [..., 'AI_NEWS']; // 한 줄 추가
|
||||
```
|
||||
|
||||
폼 모드 `NodePanel` 은 백엔드 `/api/stock/screener/nodes` 응답 기반이라 백엔드 등록만으로 자동 반영.
|
||||
|
||||
테스트 갱신:
|
||||
- `canvasLayout.test.js`: 8 score 노드, 18 엣지 (1+8+8+1), Object.keys(SCORE_NODE_NAME_MAP) === 8
|
||||
|
||||
---
|
||||
|
||||
## 16. 테스트 전략
|
||||
|
||||
### 16.1 backend 단위 테스트
|
||||
|
||||
| 파일 | 검증 |
|
||||
|------|------|
|
||||
| `test_ai_news_scraper.py` | 네이버 HTML mock 파싱 (3종목 fixture, 빈 HTML, 404 응답) |
|
||||
| `test_ai_news_analyzer.py` | anthropic mock — success / JSON 파싱 실패 / score 범위 클램프 |
|
||||
| `test_ai_news_pipeline.py` | 5종목 mini integration (scraper/analyzer monkeypatch) — top_pos/top_neg 정렬 검증, failures 격리 검증 |
|
||||
| `test_ai_news_node.py` | AiNewsSentiment.compute() — percentile_rank 결과, min_news_count 필터, 빈 컨텍스트 |
|
||||
| `test_screener_schema.py` | news_sentiment DDL 생성 확인 (기존 테스트 보강) |
|
||||
| `test_screener_router.py` | POST /snapshot/refresh-news-sentiment 라우팅 검증 (mock pipeline) |
|
||||
|
||||
### 16.2 frontend 회귀 테스트
|
||||
|
||||
| 파일 | 검증 |
|
||||
|------|------|
|
||||
| `canvasLayout.test.js` (수정) | SCORE_NODE_NAME_MAP 8 entries, EDGES 18, AI_NEWS가 gate→score→combine 경로 가짐 |
|
||||
|
||||
### 16.3 수동 검증 체크리스트
|
||||
|
||||
배포 전 NAS에서:
|
||||
- [ ] 08:00 cron 트리거 (수동 `agent-office.on_ai_news_schedule()`)
|
||||
- [ ] news_sentiment 테이블에 100종목 행 생성 확인
|
||||
- [ ] 텔레그램 메시지 호재/악재 Top 5 + 비용 라인 정상 표시
|
||||
- [ ] 16:30 스크리너 잡이 ai_news 점수 가중합에 반영 (스크리너 결과의 scores.ai_news 컬럼 확인)
|
||||
- [ ] 캔버스 모드에 🤖 AI 뉴스 노드 표시, 활성/비활성 토글 동작
|
||||
- [ ] LLM 실패 시뮬레이션 (ANTHROPIC_API_KEY 잘못 설정 후 cron) → fail-soft 동작
|
||||
|
||||
---
|
||||
|
||||
## 17. 배포
|
||||
|
||||
- **백엔드**: stock-lab + agent-office 동시 변경 → git push → Gitea webhook → 자동 deployer rsync + docker compose build
|
||||
- **DB 마이그레이션**: `ensure_screener_schema(conn)` 의 `CREATE TABLE IF NOT EXISTS` 로 자동 (기존 패턴)
|
||||
- **환경변수**: stock-lab docker-compose.yml 에 `AI_NEWS_MODEL` (옵션) 추가 가능. 기본값 `claude-haiku-4-5-20251001`
|
||||
- **프론트**: web-ui에서 `npm run release:nas` (캔버스 노드 1개 추가는 작은 변경)
|
||||
|
||||
---
|
||||
|
||||
## 18. 후속 슬라이스 후보 (이번 슬라이스 NOT)
|
||||
|
||||
본 슬라이스 완료 후 자연스럽게 이어질 작업:
|
||||
|
||||
1. **URL 단위 캐싱** — 뉴스 분석 비용 ~70% 절감
|
||||
2. **장중 16:00 추가 sentiment cron** — 16:30 스크리너에 더 신선한 데이터 공급
|
||||
3. **종목별 sentiment 트렌드 차트** — 데이터 1-2주 누적 후 시각화
|
||||
4. **시총 200~500 확장** — 중소형주 sentiment 커버리지
|
||||
5. **백테스트** — sentiment 점수가 실수익에 미친 영향 회귀
|
||||
6. **다국어/거시 뉴스 통합** — 글로벌 시장 영향 변수 추가
|
||||
7. **알림 토글** — 운영 중 텔레그램 알림 일시 정지 옵션
|
||||
8. **종목별 sentiment 페이지** — 상세 뉴스 + 점수 + LLM 근거 가시화
|
||||
|
||||
---
|
||||
|
||||
## 19. 리스크와 완화
|
||||
|
||||
| 리스크 | 완화 |
|
||||
|--------|------|
|
||||
| 네이버 finance HTML 구조 변경 | 단위 테스트로 빠른 감지. fail-soft (해당 종목 skip). 운영 알림 (실패율 > 30%) |
|
||||
| LLM 응답이 JSON 깨짐 | 종목당 1콜 + JSON-mode prompt + 파싱 실패 시 단일 종목만 skip. lotto curator에서 검증된 패턴 |
|
||||
| 네이버 차단 (429) | Semaphore(10) + 0.2초 sleep + httpx User-Agent. 향후 429 응답 시 exponential backoff 추가 |
|
||||
| anthropic API 비용 폭증 | 일 1회 100종목 = $0.075 상한. 토큰 모니터링 컬럼 + 텔레그램 표시로 즉시 감지 |
|
||||
| 08:00 cron이 16:30 잡과 lock 충돌 | _conn() WAL + busy_timeout=120s 로 흡수. 두 cron 시간 8.5시간 차이로 실질 충돌 없음 |
|
||||
| 16:30 시점 news_sentiment 비어 있음 (cron 실패) | AiNewsSentiment.compute() 가 빈 Series → 가중합에서 자동 제외. 다른 7노드 점수만 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 20. 완료 조건 (Definition of Done)
|
||||
|
||||
- [ ] 평일 08:00 KST agent-office cron 등록, 수동 트리거로 실행 검증
|
||||
- [ ] news_sentiment 테이블에 100종목 데이터 일별 생성
|
||||
- [ ] 텔레그램에 호재/악재 Top 5 + 비용 라인 표시
|
||||
- [ ] 16:30 스크리너 잡에서 ai_news 점수가 가중합에 반영 (scores.ai_news 노출)
|
||||
- [ ] 캔버스 모드에 8번째 노드 🤖 AI 뉴스 표시, 가중치/활성/파라미터 편집 동작
|
||||
- [ ] 폼 모드 NodePanel에 AI 뉴스 자동 노출 (백엔드 메타 기반)
|
||||
- [ ] 16.1 단위 테스트 모두 통과
|
||||
- [ ] 16.3 수동 검증 체크리스트 모두 통과
|
||||
- [ ] LLM 실패 시 fail-soft 동작 (전체 잡은 성공으로 끝나고, 실패 종목만 누락)
|
||||
505
docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
Normal file
505
docs/superpowers/specs/2026-05-13-screener-node-canvas-design.md
Normal file
@@ -0,0 +1,505 @@
|
||||
# Stock Screener — Node Canvas Mode Design
|
||||
|
||||
**작성일**: 2026-05-13
|
||||
**작성자**: gahusb
|
||||
**상태**: Approved for implementation
|
||||
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — react-flow 노드 캔버스 후속 슬라이스)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`/stock/screener` 페이지에 **n8n 스타일 노드 캔버스 모드**를 추가한다. 폼 모드와 토글로 전환하며, 같은 settings state를 공유한다. 백엔드는 변경하지 않는다 — 캔버스는 시각화 + 편집 UI일 뿐, 결과적으로는 동일한 `weights / node_params / gate_params` 를 `/api/stock/screener/run` 에 전송한다.
|
||||
|
||||
**Why**: 사용자가 슬라이더만 들여다보는 폼 모드는 "어떤 노드가 어떤 단계에서 무엇을 하는지"의 파이프라인 감각이 약하다. n8n/Figma류 캔버스 시각화는 데이터 흐름을 한눈에 보여줘 강세주 분석 모델의 구조적 이해를 돕는다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
**포함 (이번 슬라이스)**:
|
||||
- 헤더 토글 (`폼 ↔ 캔버스`) — 데스크탑 전용
|
||||
- 11개 노드의 미니 파이프라인 시각화 (고정 토폴로지)
|
||||
- 점수 노드 카드 위 가중치/활성/핵심 파라미터 인라인 편집 + 설명 표시
|
||||
- floating 미니 툴바 (실행 / 저장 실행 / 설정 영구 저장 / 레이아웃 리셋)
|
||||
- 노드 위치 localStorage 저장 + 초기화 버튼
|
||||
- 모바일에서는 캔버스 토글 숨김, 폼 강제
|
||||
|
||||
**범위 외 (NOT)**:
|
||||
- 노드 추가/삭제 UI (토폴로지 고정)
|
||||
- 노드 간 연결선 사용자 편집
|
||||
- 자유 그래프 모드 (별도 후속 슬라이스)
|
||||
- 캔버스 안 결과 노드에 결과 표시 (외부 테이블에만 표시)
|
||||
- 노드 캔버스 화면 자체에서의 대화형 백테스트
|
||||
- dagre 등 자동 레이아웃 알고리즘
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처 개요
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Screener.jsx (entrypoint) │
|
||||
│ - useScreenerMode (form|canvas) │
|
||||
│ - useIsMobile() → 강제 form │
|
||||
└────────────┬────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
form mode canvas mode shared result area
|
||||
(기존 그대로) (신규) (기존 그대로)
|
||||
│ │ │
|
||||
┌──────────┴──┐ ┌─────────┴──────┐ ┌────┴──────┐
|
||||
│ GatePanel │ │ ScreenerCanvas │ │ ResultTable
|
||||
│ NodePanel │ │ + CanvasToolbar│ │ TelegramPreview
|
||||
│ GlobalControls│ │ + Node cards │ │ RunHistoryList
|
||||
└──────────────┘ └─────────────────┘ └───────────┘
|
||||
↑ ↑ ↑
|
||||
└────────────────┴────────────────┘
|
||||
공유 state: useScreenerSettings,
|
||||
useScreenerRun, useScreenerHistory
|
||||
```
|
||||
|
||||
**의존성 추가**: `@xyflow/react` (구 react-flow, MIT, ~50KB gzipped).
|
||||
|
||||
**백엔드 변경 없음**. 캔버스는 settings를 동일한 형태로 만들고, 동일한 `/run` 엔드포인트를 호출한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 레이아웃
|
||||
|
||||
### 4.1 데스크탑 — 캔버스 모드
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Header: 스크리너 [폼] [캔버스] │
|
||||
│ 최근 자동잡: 2026-05-13 · 분석 기준일: 2026-05-13│
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ ╔═════════════════════════════════════════════════════╗ │
|
||||
│ ║ ┌─ floating toolbar ──────────────────────────┐ ║ │
|
||||
│ ║ │ ▶ 실행 💾 저장 실행 📌 설정 저장 🔄 ⛶ │ ║ │
|
||||
│ ║ └──────────────────────────────────────────────┘ ║ │
|
||||
│ ║ ║ │
|
||||
│ ║ ┌─────┐ ┌──────┐ ┌───────┐ ║ │
|
||||
│ ║ │📥KRX│→ │🛡️위생│ ┬→│외국인 │ ┐ ║ │
|
||||
│ ║ │data │ │gate │ ├→│거래량 │ │ ┌─────────────┐ ║ │
|
||||
│ ║ └─────┘ └──────┘ ├→│모멘텀 │ ┼→ │⚙️가중합+TopN │→ │📊│║│
|
||||
│ ║ ├→│52w고가│ │ │ +ATR 사이저 │ ║ │
|
||||
│ ║ ├→│RS │ │ └─────────────┘ ║ │
|
||||
│ ║ ├→│이평선│ ┤ ║ │
|
||||
│ ║ └→│VCP │ ┘ ║ │
|
||||
│ ║ ║ │
|
||||
│ ║ (캔버스 영역: 화면 높이의 약 60-65%) ║ │
|
||||
│ ╚═══════════════════════════════════════════════════════╝ │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ ResultTable (기존 그대로) — 비교 모드 그대로 │
|
||||
│ TelegramPreview (기존 그대로) │
|
||||
│ RunHistoryList (기존 그대로 — 우측 사이드) │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**그리드 구성 (캔버스 모드)**:
|
||||
|
||||
- Row 1 — 헤더 (높이 자동)
|
||||
- Row 2 — 캔버스 영역 (`min-height: 60vh`, `max-height: 70vh`)
|
||||
- Row 3 — 2-column: 좌측 `ResultTable + TelegramPreview` (flex 1), 우측 `RunHistoryList` (width 300px)
|
||||
|
||||
폼 모드의 3-column 그리드(좌 사이드/센터/우 사이드)와 달리, 캔버스 모드는 캔버스가 가로 전체를 쓰고 결과 영역만 2-column으로 분리. `RunHistoryList` 의 위치는 두 모드 모두 "우측 결과 사이드"로 일관.
|
||||
|
||||
### 4.2 데스크탑 — 폼 모드
|
||||
|
||||
기존 layout 그대로. 헤더에 토글 [폼] [캔버스]만 추가.
|
||||
|
||||
### 4.3 모바일 (<768px)
|
||||
|
||||
기존 모바일 카드 layout 그대로. 헤더 토글 자체를 렌더하지 않음. localStorage에 `mode='canvas'`로 저장돼 있어도 무시.
|
||||
|
||||
---
|
||||
|
||||
## 5. 노드 종류
|
||||
|
||||
총 11개 노드, 4개 카테고리.
|
||||
|
||||
| 카테고리 | 노드 | 편집 | 색상 | 표시 정보 |
|
||||
|----------|------|------|------|-----------|
|
||||
| **데이터** | `📥 KRX 데이터` | 불가 | 회색 | "~2,800종목 · FDR" |
|
||||
| **게이트** | `🛡️ 위생 게이트` | 가능 | 노랑 | 파라미터 (min_market_cap 등) + 활성/비활성 |
|
||||
| **점수** | `📈 외국인` | 가능 | 컬러 | 가중치 + 핵심 파라미터 + 설명 |
|
||||
| **점수** | `📊 거래량 급증` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `🚀 모멘텀` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `🔝 52w 고가` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `💪 RS Rating` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `📉 이평선 정렬` | 가능 | 컬러 | 동일 |
|
||||
| **점수** | `🌀 VCP-lite` | 가능 | 컬러 | 동일 |
|
||||
| **결합** | `⚙️ 가중합+TopN+ATR` | 불가 | 회색 | "TopN=10 · ATR×2" 등 현재 settings 요약 |
|
||||
| **결과** | `📊 결과` | 불가 | 회색 | "마지막 실행: 2026-05-13 · 8종목 통과" |
|
||||
|
||||
점수 노드의 컬러는 기존 `NODE_META` 의 accent color 시스템과 동기화 — 폼 모드에서 쓰던 색상이 캔버스에서도 동일하게 적용.
|
||||
|
||||
---
|
||||
|
||||
## 6. 노드 카드 디자인
|
||||
|
||||
### 6.1 점수 노드 카드 (편집 가능)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ 📈 거래량 급증 ⓘ │ ← 호버 시 풀 설명 툴팁
|
||||
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||
│ "20일 평균 대비 2배 이상" │ ← 항상 표시되는 한 줄 요약
|
||||
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||
│ 가중치 [█████░░░░░] 0.5 │ ← 슬라이더 (0~1, step 0.05)
|
||||
│ ☑ 활성 │ ← 체크박스. uncheck = weight 0
|
||||
│ │
|
||||
│ ▾ 파라미터 (펼치면) │
|
||||
│ lookback_days: [ 20 ] 일 │
|
||||
│ multiplier: [2.0 ] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 한 줄 요약: 기존 `NODE_META[name].summary` (없으면 `description` 첫 줄)
|
||||
- 풀 설명 (호버 툴팁): 기존 `NODE_META[name].description`
|
||||
- 파라미터 폼: `param_schema` 기반 자동 생성 (기존 `NodeCard.jsx` 와 동일 로직 재사용)
|
||||
|
||||
### 6.2 게이트 노드 카드 (편집 가능, 노랑)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ 🛡️ 위생 게이트 ⓘ │
|
||||
│ "통과해야 점수 단계 진입" │
|
||||
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
|
||||
│ ☑ 활성 │
|
||||
│ ▾ 파라미터 │
|
||||
│ min_market_cap: [50] 억원 │
|
||||
│ exclude_spac: ☑ │
|
||||
│ ... │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 고정 노드 카드 (정보 표시만, 회색)
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ 📥 KRX 데이터 │
|
||||
│ ~2,800종목 · FDR │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
결합 노드는 동적으로 현재 settings를 요약 표시:
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│ ⚙️ 가중합 + TopN + ATR │
|
||||
│ Top 10 · RR 2.0 · ATR×2 │ ← settings에서 계산해서 표시
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
결과 노드도 동적:
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 📊 결과 │
|
||||
│ 마지막 실행: 14:32 │
|
||||
│ 8 / 12 종목 통과 │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 캔버스 인터랙션
|
||||
|
||||
| 동작 | 결과 |
|
||||
|------|------|
|
||||
| 노드 드래그 | 위치 변경 → 드래그 종료 시 `screener-canvas-layout-v1` localStorage에 저장 |
|
||||
| 슬라이더 변경 | `useScreenerSettings.setLocal({...settings, weights: {...}})` → `dirty=true` |
|
||||
| 체크박스 (활성) | weight 토글: uncheck 시 weight=0 저장, check 시 이전 값 복원 (default = 0.5) |
|
||||
| 파라미터 ▾ 펼치기 | 카드 높이 동적 확장 |
|
||||
| 마우스 휠 | 줌 (React Flow 기본) |
|
||||
| 드래그 (빈 공간) | 팬 (React Flow 기본) |
|
||||
| ⛶ fitView 버튼 | 전체 노드 화면 맞춤 |
|
||||
| 🔄 레이아웃 리셋 | `INITIAL_NODE_POSITIONS` 로 복귀, localStorage 키 삭제 |
|
||||
| ▶ 실행 | 기존 `runPreview(settings)` → 결과는 하단 ResultTable |
|
||||
| 💾 저장 실행 | 기존 `runSave(settings)` → DB 영구화 |
|
||||
| 📌 설정 저장 | 기존 `save()` (settings 영구화) |
|
||||
|
||||
엣지 연결선은 사용자가 편집할 수 없음 (고정). React Flow 인스턴스 prop `nodesConnectable={false}`, `edgesUpdatable={false}`.
|
||||
|
||||
---
|
||||
|
||||
## 8. 컴포넌트 분해 (신규 파일)
|
||||
|
||||
```
|
||||
src/pages/stock/screener/
|
||||
Screener.jsx ← 모드 토글 추가, canvas 모드 분기 렌더
|
||||
hooks/
|
||||
useScreenerMode.js ← 신규: 'form' | 'canvas' state + localStorage
|
||||
useCanvasLayout.js ← 신규: 노드 위치 read/write/reset
|
||||
(기존 hooks 그대로)
|
||||
components/
|
||||
ModeToggle.jsx ← 신규: [폼][캔버스] 세그먼트 컨트롤 (헤더용)
|
||||
canvas/
|
||||
CanvasLayout.jsx ← 신규: 캔버스 + 결과 영역 그리드 (4.1 그리드 구성)
|
||||
ScreenerCanvas.jsx ← React Flow 루트 컨테이너
|
||||
CanvasToolbar.jsx ← floating Panel (실행/저장/리셋/fitView)
|
||||
nodes/
|
||||
ScoreNodeCard.jsx ← 점수 노드 카드 (편집)
|
||||
GateNodeCard.jsx ← 게이트 노드 카드 (편집)
|
||||
FixedNodeCard.jsx ← 데이터/결합/결과 카드 (정보만)
|
||||
constants/
|
||||
canvasLayout.js ← INITIAL_NODE_POSITIONS / EDGES / NODE_KIND_MAP
|
||||
(기존 components 그대로 — 폼 모드에서 계속 사용)
|
||||
```
|
||||
|
||||
기존 컴포넌트(`GatePanel`, `NodePanel`, `GlobalControls`, `ResultTable`, `TelegramPreview`, `RunHistoryList`)는 **변경 없음**. 결과 영역은 모드와 무관하게 동일.
|
||||
|
||||
### 8.1 `Screener.jsx` 변경점
|
||||
|
||||
```jsx
|
||||
const { mode, setMode } = useScreenerMode();
|
||||
const isMobile = useIsMobile();
|
||||
const effectiveMode = isMobile ? 'form' : mode;
|
||||
|
||||
return (
|
||||
<div className="screener-page">
|
||||
<header className="screener-header">
|
||||
<h1>스크리너</h1>
|
||||
{!isMobile && (
|
||||
<ModeToggle value={mode} onChange={setMode} />
|
||||
)}
|
||||
</header>
|
||||
|
||||
{effectiveMode === 'form' ? (
|
||||
<FormLayout {...sharedProps} /> /* 기존 grid layout */
|
||||
) : (
|
||||
<CanvasLayout {...sharedProps} /> /* 신규 — 캔버스 + 동일 결과 영역 */
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 데이터 / state 설계
|
||||
|
||||
### 9.1 localStorage 키
|
||||
|
||||
| 키 | shape | 설명 |
|
||||
|----|-------|------|
|
||||
| `screener-mode-v1` | `'form' \| 'canvas'` | 마지막 사용 모드 |
|
||||
| `screener-canvas-layout-v1` | `{ [nodeId: string]: { x: number, y: number } }` | 노드별 좌표 |
|
||||
|
||||
### 9.2 `useScreenerMode`
|
||||
|
||||
```js
|
||||
export function useScreenerMode() {
|
||||
const [mode, setModeState] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('screener-mode-v1') || 'form';
|
||||
} catch { return 'form'; }
|
||||
});
|
||||
const setMode = (m) => {
|
||||
setModeState(m);
|
||||
try { localStorage.setItem('screener-mode-v1', m); } catch {}
|
||||
};
|
||||
return { mode, setMode };
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 `useCanvasLayout`
|
||||
|
||||
```js
|
||||
export function useCanvasLayout(initialPositions) {
|
||||
const STORAGE_KEY = 'screener-canvas-layout-v1';
|
||||
const [positions, setPositions] = useState(() => readOrInit(initialPositions));
|
||||
|
||||
const updateNodePosition = (nodeId, pos) => {
|
||||
setPositions((prev) => {
|
||||
const next = { ...prev, [nodeId]: pos };
|
||||
writeSafe(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const reset = () => {
|
||||
setPositions(initialPositions);
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch {}
|
||||
};
|
||||
return { positions, updateNodePosition, reset };
|
||||
}
|
||||
```
|
||||
|
||||
`readOrInit` 은 JSON.parse 실패하거나 노드 ID가 누락된 경우 누락된 ID에 대해서만 `initialPositions` 값을 보충.
|
||||
|
||||
### 9.4 `canvasLayout.js` 상수
|
||||
|
||||
```js
|
||||
export const NODE_IDS = {
|
||||
DATA: 'data',
|
||||
GATE: 'gate-hygiene',
|
||||
FOREIGN: 'score-foreign-buy',
|
||||
VOLUME: 'score-volume-surge',
|
||||
MOMENTUM: 'score-momentum',
|
||||
HIGH52W: 'score-high52w',
|
||||
RS: 'score-rs-rating',
|
||||
MA: 'score-ma-alignment',
|
||||
VCP: 'score-vcp-lite',
|
||||
COMBINE: 'combine',
|
||||
RESULT: 'result',
|
||||
};
|
||||
|
||||
export const INITIAL_NODE_POSITIONS = {
|
||||
[NODE_IDS.DATA]: { x: 40, y: 280 },
|
||||
[NODE_IDS.GATE]: { x: 240, y: 280 },
|
||||
[NODE_IDS.FOREIGN]: { x: 480, y: 0 },
|
||||
[NODE_IDS.VOLUME]: { x: 480, y: 90 },
|
||||
[NODE_IDS.MOMENTUM]: { x: 480, y: 180 },
|
||||
[NODE_IDS.HIGH52W]: { x: 480, y: 270 },
|
||||
[NODE_IDS.RS]: { x: 480, y: 360 },
|
||||
[NODE_IDS.MA]: { x: 480, y: 450 },
|
||||
[NODE_IDS.VCP]: { x: 480, y: 540 },
|
||||
[NODE_IDS.COMBINE]: { x: 800, y: 280 },
|
||||
[NODE_IDS.RESULT]: { x: 1080, y: 280 },
|
||||
};
|
||||
|
||||
export const EDGES = [
|
||||
{ id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
|
||||
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
|
||||
id: `e-gate-${k.toLowerCase()}`, source: NODE_IDS.GATE, target: NODE_IDS[k],
|
||||
})),
|
||||
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
|
||||
id: `e-${k.toLowerCase()}-combine`, source: NODE_IDS[k], target: NODE_IDS.COMBINE,
|
||||
})),
|
||||
{ id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT },
|
||||
];
|
||||
```
|
||||
|
||||
총 엣지 수: 1(data→gate) + 7(gate→점수) + 7(점수→combine) + 1(combine→result) = **16개**.
|
||||
|
||||
---
|
||||
|
||||
## 10. 시각 디자인 디테일
|
||||
|
||||
| 요소 | 스타일 |
|
||||
|------|--------|
|
||||
| 캔버스 배경 | `bg-screener-canvas` (다크 그리드, 점선 `#1f2937`) |
|
||||
| 고정 노드 카드 | 배경 `#1f2937`, 텍스트 `#9ca3af`, 200×64 |
|
||||
| 게이트 카드 | accent `#facc15` (노랑) 좌측 4px stripe, 220×auto |
|
||||
| 점수 카드 | accent = 기존 `NODE_META[name].color`, 240×auto |
|
||||
| 비활성 점수 카드 | opacity 0.45 + grayscale 0.6 |
|
||||
| 엣지 (active) | `#fbbf24` 1.5px, 약한 그라데이션 |
|
||||
| 엣지 (해당 점수 노드 weight=0) | `#374151` 1px, 점선 |
|
||||
| 미니맵 | **사용하지 않음** (캔버스 크기가 작아 불필요) |
|
||||
| Controls (줌/리셋) | React Flow `<Controls />` 좌하단, 미니멀 |
|
||||
| floating toolbar | 좌상단, `position: absolute`, `backdrop-filter: blur(8px)`, 반투명 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 모바일/엣지 케이스
|
||||
|
||||
| 케이스 | 처리 |
|
||||
|--------|------|
|
||||
| 모바일 진입 (≤768px) | 토글 미렌더, `effectiveMode = 'form'` 강제 |
|
||||
| 데스크탑 → 모바일 리사이즈 중 | `useIsMobile` 가 자동 감지 → 폼으로 폴백 |
|
||||
| localStorage 파싱 실패 | catch + reset → 초기 위치/모드로 복귀 |
|
||||
| 노드 ID 누락 (마이그레이션) | 누락 노드만 `INITIAL_NODE_POSITIONS` 값 사용, 나머지는 저장값 유지 |
|
||||
| 노드 ID 신규 추가 (후속) | 같은 누락 처리 로직으로 자동 흡수 |
|
||||
| React Flow 초기 렌더 깜빡임 | `fitView` 초기 옵션 + `defaultViewport` 명시로 흡수 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 테스트 전략
|
||||
|
||||
캔버스는 시각화 위주라 E2E 테스트 비용이 크므로 **단위 테스트 중심**으로 간다.
|
||||
|
||||
### 12.1 단위 테스트 (web-ui)
|
||||
|
||||
| 파일 | 검증 |
|
||||
|------|------|
|
||||
| `useScreenerMode.test.js` | 초기값 'form', set 후 localStorage 반영, 손상 시 fallback |
|
||||
| `useCanvasLayout.test.js` | 초기 positions 반환, updateNodePosition 후 localStorage 반영, reset 후 storage 삭제, 손상 시 initial 반환, 누락 ID 시 initial 보충 |
|
||||
| `canvasLayout.test.js` | EDGES 정합성: 모든 점수 노드가 gate 입력과 combine 출력을 가짐, source/target ID가 NODE_IDS 안에 존재 |
|
||||
| `ScoreNodeCard.test.jsx` | 슬라이더 onChange 호출, 비활성 체크박스 시 weight=0, 활성 복원 시 default 0.5 |
|
||||
|
||||
### 12.2 통합 (가볍게)
|
||||
|
||||
- `Screener.test.jsx` 회귀: 폼 모드 기본 렌더 후 토글로 캔버스 진입, 다시 폼으로 — settings state 유지 확인
|
||||
|
||||
### 12.3 수동 검증 체크리스트
|
||||
|
||||
배포 전 데스크탑 브라우저:
|
||||
- [ ] 토글 폼↔캔버스 전환 시 가중치 동기화
|
||||
- [ ] 캔버스에서 슬라이더 → `dirty` 표시 정상
|
||||
- [ ] `▶ 실행` → 하단 ResultTable 갱신
|
||||
- [ ] 노드 드래그 → 새로고침 후 위치 복원
|
||||
- [ ] `🔄` 리셋 → 초기 위치로 복귀
|
||||
- [ ] 모바일 (DevTools 360×640) → 토글 미표시, 폼 강제
|
||||
|
||||
---
|
||||
|
||||
## 13. 성능
|
||||
|
||||
| 항목 | 평가 |
|
||||
|------|------|
|
||||
| 번들 사이즈 | `@xyflow/react` ~50KB gzipped + 노드 카드 컴포넌트 ~5KB. 전체 web-ui 번들 영향 미미 |
|
||||
| 렌더 비용 | 11개 노드, 16개 엣지 — React Flow 권장 한계 대비 매우 작음 |
|
||||
| localStorage I/O | 노드 드래그 종료(`onNodeDragStop`) 시점에만 write, 드래그 중 빈번한 write 없음 |
|
||||
| 모바일 폴백 | useIsMobile 분기로 캔버스 컴포넌트 자체를 mount하지 않음 → 모바일 번들 부담 없음 (lazy import 검토 가치 있음) |
|
||||
|
||||
`@xyflow/react` 는 데스크탑 진입 시에만 필요하므로 **`React.lazy` + `Suspense` 로 분리 import** 권장 (Plan에서 task로 명시).
|
||||
|
||||
---
|
||||
|
||||
## 14. 후속 슬라이스 후보 (이번 슬라이스 NOT)
|
||||
|
||||
이번 캔버스 슬라이스가 완료된 이후 자연스럽게 이어질 수 있는 작업들:
|
||||
|
||||
1. **노드 추가/삭제 UI** — 캔버스 우클릭 메뉴로 점수 노드 추가/제거 (백엔드 registry 동적 등록 필요)
|
||||
2. **자유 그래프 모드** — 토폴로지 자체를 사용자가 구성 (엔진 재설계 동반)
|
||||
3. **캔버스 안 결과 노드 펼치기** — 결과 노드 클릭 시 in-canvas 결과 표
|
||||
4. **캔버스 백테스트 시각화** — 노드별 기여도 히트맵 (후속 백테스트 슬라이스와 연동)
|
||||
5. **노드 그룹화** — 점수 노드 7개를 묶어 접기/펼치기
|
||||
6. **키보드 단축키** — Space=실행, Cmd+S=저장, R=리셋
|
||||
|
||||
---
|
||||
|
||||
## 15. 리스크와 완화
|
||||
|
||||
| 리스크 | 완화 |
|
||||
|--------|------|
|
||||
| `@xyflow/react` API 변경 (v11 → v12 transition 중) | spec 작성 시점 안정 버전(`12.x`) 고정, package.json에 명시 |
|
||||
| 캔버스 모드에서 폼 모드 settings와 동기화 깨짐 | 같은 hook 인스턴스 공유 + Screener.jsx 한 컴포넌트가 두 layout 분기 렌더 → 동일 state 자동 공유 |
|
||||
| 노드 카드가 너무 커서 캔버스 빽빽 | spec 6장의 카드 폭(220~240px), 점수 노드 세로 90px 간격으로 사전 검증된 좌표 사용 |
|
||||
| localStorage 무한 누적 | 키는 정해진 1개씩만 사용, 마이그레이션 시 키 명에 -v1 suffix |
|
||||
| 모바일 사용자 혼란 | 토글 자체를 렌더하지 않음 → 캔버스 모드 존재 자체를 알지 못함 → 학습 부담 0 |
|
||||
|
||||
---
|
||||
|
||||
## 16. API/백엔드 영향
|
||||
|
||||
**없음**. 본 슬라이스는 프론트엔드 전용. 기존 API:
|
||||
- `GET /api/stock/screener/nodes`
|
||||
- `GET/PUT /api/stock/screener/settings`
|
||||
- `POST /api/stock/screener/run`
|
||||
|
||||
를 그대로 사용한다. settings의 shape도 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 17. 배포
|
||||
|
||||
- 프론트만 변경 → `npm run release:nas` 또는 `scripts\deploy.bat --frontend`
|
||||
- 백엔드 배포 불필요
|
||||
- 마이그레이션 불필요 (DB 변경 없음, localStorage는 점진적 적용)
|
||||
|
||||
---
|
||||
|
||||
## 18. 완료 조건 (Definition of Done)
|
||||
|
||||
- [ ] 데스크탑에서 헤더 [폼][캔버스] 토글이 보이고 정상 전환
|
||||
- [ ] 캔버스 모드에 11개 노드, 16개 엣지가 사전 정의된 위치로 표시
|
||||
- [ ] 점수 노드 카드에서 가중치 슬라이더/활성 체크박스/핵심 파라미터 편집 동작
|
||||
- [ ] 카드 ⓘ 호버 시 설명 툴팁 표시, 한 줄 요약 항상 표시
|
||||
- [ ] floating 툴바 4개 버튼 (실행/저장 실행/설정 저장/레이아웃 리셋) 모두 동작
|
||||
- [ ] 노드 드래그 → localStorage 저장 → 새로고침 후 복원
|
||||
- [ ] 🔄 리셋 → 초기 좌표 복귀 + localStorage 삭제
|
||||
- [ ] 모바일 (≤768px)에서 토글 미렌더, 폼 강제
|
||||
- [ ] 폼/캔버스 모드 전환해도 settings, 미리보기 히스토리, 결과 유지
|
||||
- [ ] 12.1의 단위 테스트 모두 통과
|
||||
- [ ] 12.3의 수동 검증 체크리스트 통과
|
||||
@@ -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 으로 자연스럽게 이어진다.
|
||||
369
docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
369
docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Confidence Signal Pipeline V2 — Phase 1: stock WebAI API Design
|
||||
|
||||
**작성일**: 2026-05-15
|
||||
**작성자**: gahusb
|
||||
**상태**: Approved for implementation
|
||||
**선행 spec**:
|
||||
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||
- stock-lab → stock graduation (`2026-05-15-stock-lab-rename-to-stock.md`) — 본 spec 부터 새 이름 `stock` 사용
|
||||
**브레인스토밍 결정 7개**: scope=B / auth=A(정적키) / portfolio shape=B(pnl_pct 추가) / news-sentiment=A(일별 dump) / endpoint 구조=1(/api/webai 분리) / rate limit=B(nginx + 인증 로그) / 테스트=B(pytest schema 검증)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
Confidence Signal Pipeline V2 의 Phase 2 (web-ai pull worker) 가 stock 컨테이너에서 polling 으로 가져갈 **입력 계약 3종**을 stock 측에 신설.
|
||||
|
||||
stock 의 가치 발굴 데이터 (portfolio, news sentiment, screener 점수) 를 web-ai 가 안전하게 polling 할 수 있는 인증된 endpoint 묶음 = Phase 2 진입 전 필수 의존성.
|
||||
|
||||
**Why**: Phase 0 §3 책임 분리 — "stock = 가치 발굴, web-ai = 시점 분석". web-ai 가 NAS DB 직접 접근 안 함, 모든 데이터는 stock API 경유. 본 Phase 가 이 API 표면을 정의.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
### 포함 (Phase 1)
|
||||
|
||||
- ① 새 endpoint `GET /api/webai/portfolio` — 기존 portfolio 응답 + `pnl_pct` 필드 보강 + `X-WebAI-Key` 인증
|
||||
- ② 새 endpoint `GET /api/webai/news-sentiment` — news_sentiment 테이블 일별 dump + 인증
|
||||
- ③ X-WebAI-Key 인증 인프라 — `verify_webai_key` FastAPI dependency, env `WEBAI_API_KEY`
|
||||
- ④ nginx `/api/webai/*` location + `limit_req` rate limit (분당 60 + burst 20)
|
||||
- ⑤ 인증 실패 logger (path + remote_addr 1회 기록)
|
||||
- ⑥ 단위 + 통합 테스트 15 케이스
|
||||
|
||||
### 범위 외 (NOT)
|
||||
|
||||
- `/api/webai/screener/run` 신규 endpoint **불필요** — web-ai 는 기존 `/api/stock/screener/run` `{mode:"preview"}` 직접 호출 (Phase 2 client 구현 시 동작 검증)
|
||||
- 기존 `/api/portfolio` 의 무인증 외부 노출 보안 강화 — 별도 슬라이스 (사용자 인증 도입은 Lab 사이트 통합 로그인 검토 시점)
|
||||
- portfolio 의 `entry_date` / `days_held` / `position_weight` 등 추가 필드 — backlog (V2 운영 후 sell signal 정밀화 시)
|
||||
- HMAC 서명, mTLS, IP allowlist — 단일 클라이언트 시나리오 + 정적 키로 충분
|
||||
- nginx rate limit 응답 시간/에러율 메트릭 + 알림 — Phase 7 운영 모니터링 슬라이스
|
||||
- 운영 .env 변경 자동화 — 사용자 1회 수동 갱신
|
||||
- web-ui 변경 — Phase 1 은 백엔드 + 인프라만
|
||||
|
||||
---
|
||||
|
||||
## 3. 변경 매트릭스
|
||||
|
||||
### 3.1 web-backend 코드
|
||||
|
||||
| 파일 | 변경 |
|
||||
|------|------|
|
||||
| `stock/app/auth.py` (신규) | `verify_webai_key()` FastAPI dependency |
|
||||
| `stock/app/main.py` | 신규 endpoint 2개: `GET /api/webai/portfolio`, `GET /api/webai/news-sentiment` (둘 다 `dependencies=[Depends(verify_webai_key)]`). portfolio 는 기존 `get_portfolio()` 호출 + `pnl_pct` 보강 mapper |
|
||||
| `stock/app/test_webai_auth.py` (신규) | `verify_webai_key` 단위 3 케이스 |
|
||||
| `stock/app/test_webai_endpoints.py` (신규) | 두 endpoint × 4 케이스 + 공통 4 케이스 = 12 케이스 |
|
||||
| `nginx/default.conf` | `limit_req_zone webai` 정의 + `/api/webai/` location + `X-WebAI-Key` 헤더 forward |
|
||||
| `docker-compose.yml` | stock 의 env 에 `WEBAI_API_KEY=${WEBAI_API_KEY}` 추가 |
|
||||
|
||||
### 3.2 운영 (사용자 1회)
|
||||
|
||||
| 파일 | 변경 |
|
||||
|------|------|
|
||||
| 운영 `.env` (NAS `/volume1/docker/webpage/.env`) | `WEBAI_API_KEY=<랜덤 32~64자>` 추가 |
|
||||
| Windows web-ai 의 `.env` | `WEBAI_API_KEY=<동일 값>` 추가 (Phase 2 진입 시점에 사용) |
|
||||
|
||||
### 3.3 web-ui
|
||||
|
||||
**변경 없음**. 기존 `/api/portfolio` 호출 무영향.
|
||||
|
||||
---
|
||||
|
||||
## 4. API 계약
|
||||
|
||||
### 4.1 `GET /api/webai/portfolio`
|
||||
|
||||
요청:
|
||||
```
|
||||
GET /api/webai/portfolio HTTP/1.1
|
||||
X-WebAI-Key: <key>
|
||||
```
|
||||
|
||||
응답 200 — 기존 `/api/portfolio` 응답 + 각 holdings 항목에 `pnl_pct` (비율) 추가 + summary 에 `total_pnl_pct` 추가:
|
||||
```json
|
||||
{
|
||||
"holdings": [
|
||||
{
|
||||
"id": 1, "broker": "키움", "ticker": "005930", "name": "삼성전자",
|
||||
"quantity": 100, "avg_price": 75000, "purchase_price": 75500,
|
||||
"current_price": 78500, "price_session": "REGULAR",
|
||||
"price_as_of": "2026-05-15T15:30:00",
|
||||
"eval_amount": 7850000, "profit_amount": 350000,
|
||||
"profit_rate": 4.67,
|
||||
"pnl_pct": 0.0467
|
||||
}
|
||||
],
|
||||
"cash": [{"broker": "키움", "cash": 1000000}],
|
||||
"summary": {
|
||||
"total_buy": 7550000, "total_eval": 7850000,
|
||||
"total_profit": 350000, "total_profit_rate": 4.67, "total_pnl_pct": 0.0467,
|
||||
"total_cash": 1000000, "total_assets": 8850000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
규칙:
|
||||
- `pnl_pct = profit_rate / 100`
|
||||
- 빈 portfolio 시 응답은 `{"holdings": [], "cash": [...], "summary": {..., "total_pnl_pct": 0.0}}`
|
||||
- `profit_rate` 가 null 인 holding (현재가 조회 실패) 의 `pnl_pct` 도 null
|
||||
|
||||
### 4.2 `GET /api/webai/news-sentiment?date=YYYY-MM-DD`
|
||||
|
||||
요청:
|
||||
```
|
||||
GET /api/webai/news-sentiment HTTP/1.1
|
||||
X-WebAI-Key: <key>
|
||||
```
|
||||
|
||||
쿼리:
|
||||
- `date` (옵션) — `YYYY-MM-DD`. 생략 시 news_sentiment 테이블의 최신 date.
|
||||
|
||||
응답 200:
|
||||
```json
|
||||
{
|
||||
"date": "2026-05-15",
|
||||
"count": 87,
|
||||
"items": [
|
||||
{"ticker": "005930", "name": "삼성전자", "score": 6.2,
|
||||
"reason": "HBM 양산 가시화", "news_count": 12, "source": "articles"},
|
||||
{"ticker": "000660", "name": "SK하이닉스", "score": 5.5,
|
||||
"reason": "...", "news_count": 8, "source": "articles"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
규칙:
|
||||
- `score` = news_sentiment.score_raw 그대로 (단위 -10 ~ +10 가정, ai_news/analyzer.py 결정)
|
||||
- `name` = krx_master JOIN (없으면 ticker 그대로)
|
||||
- `source` = 디버그용 (articles / scraper / etc.)
|
||||
- 정렬 = `score DESC` (web-ai 가 자체 필터링)
|
||||
- 테이블 empty 또는 지정 date 데이터 없음 → `{"date": null, "count": 0, "items": []}`
|
||||
|
||||
### 4.3 인증 실패 (모든 `/api/webai/*` 공통)
|
||||
|
||||
```
|
||||
HTTP/1.1 401 Unauthorized
|
||||
Content-Type: application/json
|
||||
|
||||
{"detail": "invalid or missing X-WebAI-Key"}
|
||||
```
|
||||
|
||||
- 페이로드 leak 없음 (응답에 endpoint 별 데이터 0)
|
||||
- stock logger 에 `WARNING auth_fail path=/api/webai/portfolio remote=1.2.3.4` 1회 기록 (IP 만, 키는 로그하지 않음)
|
||||
|
||||
### 4.4 운영 .env 누락 시
|
||||
|
||||
env `WEBAI_API_KEY` 가 빈 문자열 또는 미정의 시:
|
||||
- startup 시점에 stock logger 가 `ERROR WEBAI_API_KEY not configured` 1회 출력
|
||||
- `/api/webai/*` 호출은 모두 503 `{"detail": "webai auth not configured"}`
|
||||
- 다른 endpoint (`/api/portfolio`, `/api/stock/*`) 영향 없음
|
||||
|
||||
---
|
||||
|
||||
## 5. 인증 구현
|
||||
|
||||
`stock/app/auth.py`:
|
||||
```python
|
||||
import os
|
||||
import logging
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_WEBAI_API_KEY = os.getenv("WEBAI_API_KEY", "").strip()
|
||||
|
||||
def verify_webai_key(
|
||||
request: Request,
|
||||
x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"),
|
||||
):
|
||||
if not _WEBAI_API_KEY:
|
||||
logger.error("WEBAI_API_KEY not configured — refusing all /api/webai/* requests")
|
||||
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||
if not x_webai_key or x_webai_key != _WEBAI_API_KEY:
|
||||
logger.warning(
|
||||
"auth_fail path=%s remote=%s",
|
||||
request.url.path,
|
||||
request.client.host if request.client else "?",
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key")
|
||||
```
|
||||
|
||||
디자인 노트:
|
||||
- env 누락 시 import-time crash 회피 → 다른 endpoint 무영향. 호출 시점에만 503.
|
||||
- 키 비교는 `==` (constant-time 비교 불필요 — 단일 정적 키, timing attack 가치 낮음, 회전 후 즉시 무효화 가능).
|
||||
- 헤더 이름은 alias `X-WebAI-Key` (FastAPI 가 `x_webai_key` 매개변수로 받음).
|
||||
|
||||
`stock/app/main.py` 적용:
|
||||
```python
|
||||
from .auth import verify_webai_key
|
||||
|
||||
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_portfolio():
|
||||
raw = get_portfolio() # 기존 함수 그대로 호출 (내부 분리: 응답 dict 생성 로직을 함수로)
|
||||
return _augment_portfolio_with_pnl_pct(raw)
|
||||
|
||||
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||
def get_webai_news_sentiment(date: str | None = None):
|
||||
return _fetch_news_sentiment_dump(date)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. nginx config
|
||||
|
||||
`web-backend/nginx/default.conf` 변경:
|
||||
|
||||
### 6.1 `http {}` 블록 상단 (기존 limit_req_zone 옆에 추가)
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||
```
|
||||
|
||||
### 6.2 `server {}` 블록 내 신규 location (`/api/stock/` location 위에 우선순위)
|
||||
```nginx
|
||||
location /api/webai/ {
|
||||
limit_req zone=webai burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://stock:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||
}
|
||||
```
|
||||
|
||||
디자인 노트:
|
||||
- `60r/m` = 분당 60 요청, `burst=20 nodelay` = 짧은 spike 20 까지 허용.
|
||||
- web-ai 폴링 빈도 (장중 분당 3 call) 대비 20배 여유 — 정상 운영 시 절대 hit 안 됨.
|
||||
- 한도 초과 시 429. web-ai 측 retry/backoff 는 Phase 2 client 구현 (본 Phase 외).
|
||||
- `X-WebAI-Key` 헤더 명시적 forward (nginx 가 underscore 헤더를 기본 drop 하므로 dash 헤더는 OK, 그래도 안전상 명시).
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트
|
||||
|
||||
### 7.1 단위 (`stock/app/test_webai_auth.py`, 3 케이스)
|
||||
|
||||
| 케이스 | 검증 |
|
||||
|--------|------|
|
||||
| `test_verify_with_valid_key_passes` | `WEBAI_API_KEY=secret` + 헤더 `X-WebAI-Key: secret` → 통과 |
|
||||
| `test_verify_without_key_raises_401` | 헤더 누락 → HTTPException 401 |
|
||||
| `test_verify_with_wrong_key_raises_401` | 헤더 `X-WebAI-Key: wrong` → HTTPException 401 |
|
||||
|
||||
### 7.2 통합 (`stock/app/test_webai_endpoints.py`, 12 케이스)
|
||||
|
||||
FastAPI TestClient + `WEBAI_API_KEY` monkeypatch + 임시 sqlite seed.
|
||||
|
||||
portfolio:
|
||||
- `test_portfolio_normal_response_includes_pnl_pct`
|
||||
- `test_portfolio_summary_has_total_pnl_pct`
|
||||
- `test_portfolio_pnl_pct_matches_profit_rate_divided_100`
|
||||
- `test_portfolio_missing_key_returns_401`
|
||||
|
||||
news-sentiment:
|
||||
- `test_news_sentiment_returns_latest_date_when_no_param`
|
||||
- `test_news_sentiment_filters_by_date_param`
|
||||
- `test_news_sentiment_empty_table_returns_count_zero`
|
||||
- `test_news_sentiment_items_sorted_by_score_desc`
|
||||
|
||||
공통:
|
||||
- `test_401_response_has_no_payload_leak`
|
||||
- `test_503_when_webai_key_not_configured`
|
||||
- `test_wrong_key_returns_401`
|
||||
- `test_news_sentiment_unknown_date_returns_empty`
|
||||
|
||||
### 7.3 Manual smoke (배포 후)
|
||||
|
||||
```bash
|
||||
# 정상 통과
|
||||
curl -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio
|
||||
# → 200, JSON 응답에 pnl_pct 필드 존재
|
||||
|
||||
# 인증 실패
|
||||
curl -i https://gahusb.synology.me/api/webai/portfolio
|
||||
# → 401 + {"detail": "invalid or missing X-WebAI-Key"}
|
||||
|
||||
# news-sentiment
|
||||
curl -H "X-WebAI-Key: $WEBAI_API_KEY" "https://gahusb.synology.me/api/webai/news-sentiment?date=2026-05-15"
|
||||
# → 200, items 배열
|
||||
|
||||
# rate limit
|
||||
for i in {1..100}; do curl -s -o /dev/null -w "%{http_code}\n" \
|
||||
-H "X-WebAI-Key: $WEBAI_API_KEY" \
|
||||
https://gahusb.synology.me/api/webai/portfolio; done | sort | uniq -c
|
||||
# → 200 다수 + 429 일부
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 위험 및 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| 운영 .env 의 `WEBAI_API_KEY` 누락 → web-ai 호출 503 | startup 시점 ERROR log + Phase 2 web-ai 구현 시 startup health check 로 즉시 발견 |
|
||||
| 키 노출 (.env 유출) | 회전 — NAS .env + web-ai .env 동시 갱신 + 컨테이너 재기동. 다운타임 ~10초 |
|
||||
| nginx rate limit 너무 빡빡해서 web-ai 정상 폴링 차단 | `60r/m + burst=20` 은 web-ai 폴링 (분당 3 call) 대비 20배 여유. Phase 7 운영 모니터링에서 조정 |
|
||||
| pnl_pct 단위 실수 (백분율 vs 비율) | 단위 명세 (비율, 0.047) 명시 + `test_portfolio_pnl_pct_matches_profit_rate_divided_100` 으로 검증 |
|
||||
| news_sentiment 테이블 empty | 응답 `{"date": null, "count": 0, "items": []}` (테스트 케이스 포함) |
|
||||
| `/api/webai/portfolio` vs `/api/portfolio` 응답 drift | 둘 다 동일 `get_portfolio()` 내부 함수 호출 + webai 측 augment mapper 만 적용. drift 회피 |
|
||||
| nginx 가 underscore 헤더 drop | `X-WebAI-Key` (dash) 사용으로 회피. 명시적 forward 도 추가 |
|
||||
| 외부에서 endpoint 무인증 접근 시도 | logger.warning 으로 IP 1회 기록 (대량 시도 시 IDS/alert 검토는 별도) |
|
||||
| 키 brute force 시도 | nginx rate limit 분당 60 + 키 64자 랜덤 → 현실적 brute force 불가능 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 운영 영향
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| 다운타임 | ~10초 (stock + nginx 재기동) |
|
||||
| 사용자 영향 | 없음 (web-ui 무변경) |
|
||||
| 운영 .env 갱신 | 1회 (`WEBAI_API_KEY=<랜덤>`) |
|
||||
| frontend 재배포 | 불필요 |
|
||||
| 다른 lab 영향 | 없음 |
|
||||
| DB 마이그레이션 | 없음 (news_sentiment 테이블 기존, 추가 컬럼 없음) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase 1 완료 조건 (DoD)
|
||||
|
||||
- [ ] `stock/app/auth.py` 신규 + 단위 테스트 3 PASS
|
||||
- [ ] `stock/app/main.py` 의 2 신규 endpoint + 통합 테스트 12 PASS
|
||||
- [ ] `nginx/default.conf` 의 `limit_req_zone webai` + `/api/webai/` location 추가
|
||||
- [ ] `docker-compose.yml` 의 stock env `WEBAI_API_KEY` 추가
|
||||
- [ ] 운영 .env 갱신 (사용자 1회) — 본 Phase plan 의 마지막 task
|
||||
- [ ] 배포 후 manual smoke 4 항목 PASS (정상 200 / 인증 누락 401 / news-sentiment 200 / rate limit 429)
|
||||
- [ ] stock pytest 전체 86 + 신규 15 = **101 PASS**
|
||||
- [ ] web-ui 영향 없음 검증 (web-ui 의 `/api/portfolio` 정상 동작)
|
||||
|
||||
---
|
||||
|
||||
## 11. Phase 2 와의 관계
|
||||
|
||||
본 Phase 1 완료 후 즉시 **Phase 2 (web-ai pull worker + signal API client)** spec → plan → 구현. 의존성:
|
||||
|
||||
```
|
||||
[Phase 1 spec/plan/실행] → [Phase 2 spec/plan/실행]
|
||||
1주 2주
|
||||
```
|
||||
|
||||
Phase 2 의 입력 계약 = 본 spec 의 §4 API 계약. Phase 2 client 가 본 endpoint 들을 polling + 캐시 + retry.
|
||||
|
||||
Phase 2 시작 시점 검증 항목:
|
||||
- web-ai 의 `.env` 에 `WEBAI_API_KEY` 설정
|
||||
- web-ai 의 httpx client 가 `X-WebAI-Key` 헤더 자동 첨부
|
||||
- 429 응답 시 backoff 정책 (exponential, max 60s)
|
||||
- 5xx 응답 시 short retry (3회) 후 alert
|
||||
|
||||
---
|
||||
|
||||
## 12. Backlog (본 spec NOT)
|
||||
|
||||
V2 운영 후 별도 슬라이스로:
|
||||
|
||||
- `/api/webai/screener/run` 신규 endpoint — 현재 `/api/stock/screener/run` 직접 호출, drift 발견 시 분리
|
||||
- portfolio 의 `entry_date` / `days_held` / `position_weight` 추가 — sell signal 정밀화 시
|
||||
- ticker filter — news-sentiment 의 `?tickers=` 옵션 (Top-20 만 가져올 때 payload 절약)
|
||||
- 사용자 인증 도입 (Lab 사이트 통합 로그인) — 기존 `/api/portfolio` 무인증 외부 노출 해결
|
||||
- nginx 응답 시간/에러율 메트릭 + 텔레그램 alert — Phase 7 모니터링 통합
|
||||
- HMAC 서명 옵션 — 외부 노출 endpoint 추가 시 검토
|
||||
- Key rotation 자동화 — 일정 운영 안정화 후
|
||||
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 케이스 등재
|
||||
@@ -0,0 +1,267 @@
|
||||
# web-ai V1 루트 → `signal_v1/` Rename Design
|
||||
|
||||
**작성일**: 2026-05-16
|
||||
**작성자**: gahusb
|
||||
**상태**: Approved for implementation
|
||||
**선행 spec**:
|
||||
- Confidence Signal Pipeline V2 Phase 0 (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||
- stock-lab → stock graduation (`2026-05-15-stock-lab-rename-to-stock.md`) — 동일 atomic refactor 패턴
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`web-ai/` 디렉토리에 V1 자동매매 시스템 (main_server.py + modules/ + 자체 LSTM + KIS + Telegram Bot) 과 V2 시그널 파이프라인 (`signal_v2/` Phase 2 시작) 이 함께 거주할 예정. V1 자산을 모두 `signal_v1/` 하위로 격리해 신/구 분리 명확.
|
||||
|
||||
**Why**: 사용자 명시 ("기존 기능들도 봤을때 헷갈리지 않게 signal_v2에서 사용하는거 아니면 web-ai/signal_v1 으로 몰아넣어줘"). V2 Phase 6 deprecation 시점에 `rm -rf signal_v1/` 단순화. Phase 2 spec 작성 전에 새 이름 `signal_v1/` 기준으로 진행하면 후속 갱신 비용 회피.
|
||||
|
||||
본 리네이밍은 **Phase 2 brainstorming 의 도중 분기**한 별도 슬라이스 — stock-lab → stock graduation 과 동일 패턴.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `git mv` web-ai 루트의 모든 V1 자산을 `signal_v1/` 안으로:
|
||||
- 진입점: `main_server.py`, `warmup_and_restart.py`, `watchlist_manager.py`, `backtester.py`, `theme_manager.py`, `backtest_runner.py`
|
||||
- 모듈: `modules/` (전체)
|
||||
- 데이터: `data/` (전체 — runtime data 보존)
|
||||
- 테스트: `tests/` (전체)
|
||||
- 스크립트: `start.bat`
|
||||
- 문서: `KIS_SETUP.md`, `README.md`, `CLAUDE.md` (기존 V1 가이드)
|
||||
- 로그: `bot_ipc.json`, `bot_output.log`, `daily_launcher.log`, `server.log`, `telegram_bot.log`, `warmup.log`
|
||||
- `__pycache__/` (gitignore)
|
||||
- `web-ai/CLAUDE.md` 신규 — web-ai 루트의 새 가이드 (signal_v1 + signal_v2 디렉토리 안내, 공유 `.env`, Phase 6 deprecation 계획)
|
||||
- `web-ai/start.bat` 신규 — `cd signal_v1 && python main_server.py` (또는 절대 경로 형태)
|
||||
- 운영 검증: 자체 자동매매 봇 정상 기동 + Telegram Bot polling + KIS 토큰 로딩
|
||||
|
||||
### 범위 외 (NOT)
|
||||
|
||||
- Python import 경로 변경 — `signal_v1/` 안에서 진입점 실행 시 cwd 가 `signal_v1/` 이라 기존 `from modules.X` 그대로 작동. import 전면 갱신 불필요.
|
||||
- `signal_v2/` 디렉토리 생성 — Phase 2 spec 의 작업.
|
||||
- `.env` 분리 — V1 + V2 환경변수 모두 `web-ai/.env` 한 곳 (signal_v1 의 python 진입점이 cwd 기준 `.env` 로드 시 path 갱신 필요, 단순 조정).
|
||||
- `.gitignore` — 기존 패턴 그대로 (`signal_v1/__pycache__`, `signal_v1/data/*.db` 등은 일반 패턴으로 커버).
|
||||
- 다른 lab / web-backend / web-ui 영향 — 0.
|
||||
- start_signal_v2.bat — Phase 2 spec 의 작업.
|
||||
|
||||
---
|
||||
|
||||
## 3. 변경 매트릭스
|
||||
|
||||
### 3.1 web-ai 루트 (작업 전)
|
||||
|
||||
```
|
||||
web-ai/
|
||||
├── .env ← 유지
|
||||
├── .gitignore ← 유지
|
||||
├── CLAUDE.md ← signal_v1/ 로 mv (현 V1 가이드)
|
||||
├── KIS_SETUP.md ← signal_v1/ 로 mv
|
||||
├── README.md ← signal_v1/ 로 mv
|
||||
├── main_server.py ← signal_v1/ 로 mv
|
||||
├── warmup_and_restart.py ← signal_v1/ 로 mv
|
||||
├── watchlist_manager.py ← signal_v1/ 로 mv
|
||||
├── backtester.py ← signal_v1/ 로 mv
|
||||
├── backtest_runner.py ← signal_v1/ 로 mv
|
||||
├── theme_manager.py ← signal_v1/ 로 mv
|
||||
├── start.bat ← signal_v1/ 로 mv (이후 web-ai/start.bat 신규)
|
||||
├── modules/ ← signal_v1/ 로 mv
|
||||
├── data/ ← signal_v1/ 로 mv
|
||||
├── tests/ ← signal_v1/ 로 mv
|
||||
├── __pycache__/ ← signal_v1/ 로 mv (gitignore)
|
||||
├── bot_ipc.json ← signal_v1/ 로 mv
|
||||
├── bot_output.log ← signal_v1/ 로 mv
|
||||
├── daily_launcher.log ← signal_v1/ 로 mv
|
||||
├── server.log ← signal_v1/ 로 mv
|
||||
├── telegram_bot.log ← signal_v1/ 로 mv
|
||||
└── warmup.log ← signal_v1/ 로 mv
|
||||
```
|
||||
|
||||
### 3.2 web-ai 루트 (작업 후)
|
||||
|
||||
```
|
||||
web-ai/
|
||||
├── .env ← 공유 (V1 + V2 변수)
|
||||
├── .gitignore ← 기존
|
||||
├── CLAUDE.md ← 신규 (web-ai 레벨 가이드)
|
||||
├── start.bat ← 신규 (signal_v1 진입)
|
||||
├── signal_v1/
|
||||
│ ├── CLAUDE.md ← 기존 V1 가이드 (이동)
|
||||
│ ├── KIS_SETUP.md
|
||||
│ ├── README.md
|
||||
│ ├── main_server.py
|
||||
│ ├── warmup_and_restart.py
|
||||
│ ├── ... (이하 모든 V1 자산)
|
||||
│ ├── start.bat ← 이동본 (사용 안 함, 향후 정리)
|
||||
│ ├── modules/
|
||||
│ ├── data/
|
||||
│ ├── tests/
|
||||
│ └── (log 파일들)
|
||||
└── signal_v2/ ← Phase 2 작업 (본 spec 외)
|
||||
```
|
||||
|
||||
### 3.3 신규 파일 2개 — 정확한 내용
|
||||
|
||||
**`web-ai/CLAUDE.md` (신규)**:
|
||||
```markdown
|
||||
# web-ai — Workspace 가이드
|
||||
|
||||
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti) 의 두 시그널 파이프라인 컨테이너.
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
| 경로 | 역할 | 상태 |
|
||||
|------|------|------|
|
||||
| `signal_v1/` | V1 자체 자동매매 시스템 (main_server.py + Trading Bot + Telegram Bot + LSTM + Ollama + KIS 자동주문) | 운영 중. Confidence Signal Pipeline V2 Phase 6 에서 deprecation 예정 |
|
||||
| `signal_v2/` | V2 신호 파이프라인 (stock pull worker + Chronos-2 + signal API client) | Phase 2 에서 신설 |
|
||||
| `.env` | V1 + V2 환경변수 공유 | KIS_*, TELEGRAM_*, STOCK_API_URL, WEBAI_API_KEY 등 |
|
||||
| `start.bat` | V1 진입 (signal_v1 디렉토리 안 main_server.py 실행) | V2 별도 start 스크립트는 signal_v2/start.bat |
|
||||
|
||||
## 운영 가이드
|
||||
|
||||
- V1 시작: `start.bat` 또는 `cd signal_v1 && python main_server.py`
|
||||
- V2 시작 (Phase 2 이후): `cd signal_v2 && python -m uvicorn main:app --port 8001`
|
||||
- 둘 다 동시 실행 가능 (포트 분리: V1=8000, V2=8001)
|
||||
|
||||
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||
|
||||
`web-ui/docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md` 참조.
|
||||
|
||||
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조.
|
||||
```
|
||||
|
||||
**`web-ai/start.bat` (신규)**:
|
||||
```bat
|
||||
@echo off
|
||||
cd /d "%~dp0\signal_v1"
|
||||
python main_server.py
|
||||
```
|
||||
|
||||
### 3.4 운영 영향 — `.env` 로드 경로
|
||||
|
||||
기존 V1 코드 (`signal_v1/modules/config.py` 등) 는 `load_dotenv()` 호출 시 cwd 또는 절대 경로의 `.env` 를 찾음. cwd 가 `signal_v1/` 이라면 `.env` 가 `web-ai/.env` (parent) 이라 못 찾을 수 있음.
|
||||
|
||||
**해결**: 진입점 (`signal_v1/main_server.py` 등) 의 `load_dotenv()` 호출에 명시적 경로 추가:
|
||||
```python
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# web-ai/.env (signal_v1/ 의 parent) 명시 로드
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
```
|
||||
|
||||
작업 매트릭스:
|
||||
- `signal_v1/main_server.py` 의 `load_dotenv()` 1-2 줄 갱신
|
||||
- `signal_v1/warmup_and_restart.py` 동일
|
||||
- `signal_v1/modules/config.py` 같은 환경변수 로딩 위치 점검
|
||||
|
||||
---
|
||||
|
||||
## 4. 작업 순서
|
||||
|
||||
```
|
||||
1. 사전 검토 (10분)
|
||||
- web-ai 자체 자동매매 봇 운영 중 → 작업 시간대 결정 (장외: 평일 16:00 이후 / 주말)
|
||||
- 본 spec §3 매트릭스 모든 파일 grep cross-check
|
||||
- .env 로드 위치 grep — `load_dotenv` 호출 모두 찾기
|
||||
- 데이터 파일 (data/, *.log, *.json) 손실 위험 없음 확인 (git mv 는 history 보존)
|
||||
|
||||
2. atomic refactor (1 commit)
|
||||
- mkdir signal_v1
|
||||
- git mv (위 매트릭스 항목 전부) signal_v1/
|
||||
- signal_v1/main_server.py 외 .env 로드 위치 갱신
|
||||
- web-ai/CLAUDE.md 신규
|
||||
- web-ai/start.bat 신규
|
||||
|
||||
3. 로컬 검증 (cwd=signal_v1)
|
||||
- python -m pytest tests/unit -q (기존 V1 테스트 통과)
|
||||
- python main_server.py 시작 검증
|
||||
- .env 로딩 확인 (KIS / Telegram / Ollama 환경변수)
|
||||
- 봇 정상 시작 → telegram 알림 도착 → /status 응답 → 종료
|
||||
|
||||
4. git push (web-ai repo)
|
||||
- sub Gitea: https://gitea.gahusb.synology.me/gahusb/ai-trade.git
|
||||
- 본 작업은 NAS deploy 와 무관 (web-ai 는 로컬 Windows 머신).
|
||||
|
||||
5. 사용자 수동 검증
|
||||
- 시장 시작 (다음 평일 09:00) 시점 봇 정상 동작 확인 또는 일/주말 가짜 트리거
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 위험 및 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| `.env` 로드 실패 → KIS 토큰 못 가져옴 → 자동매매 중단 | 진입점 (main_server.py / warmup_and_restart.py) 의 `load_dotenv` 명시 경로 추가. 시작 직후 KIS auth 확인 |
|
||||
| 자동매매 중 작업 → 거래 중단 | 작업 시간대를 장외 (평일 16:00+ 또는 주말) 로 제한 |
|
||||
| Python import 회귀 | `signal_v1/` cwd 기준 `from modules.X` 그대로. 외부 import 불필요. 기존 76+ 테스트 통과로 검증 |
|
||||
| 데이터 파일 (data/models/, data/ensemble_history.json 등) 손실 | git mv 사용 — history 보존, 파일 내용 무변경. 사전 git status 로 dirty 없음 확인 |
|
||||
| Telegram Bot 중복 polling (이전 프로세스 미종료) | start.bat 재시작 시 main_server.py 의 좀비 정리 로직 자동 동작 |
|
||||
| .env 의 절대 경로 참조 (e.g. `data/kis_token.json` 같은 상대 경로) | cwd 변경 영향 — 진입점이 working directory 를 `signal_v1/` 으로 설정하면 기존 상대 경로 그대로 작동. start.bat 의 `cd /d "%~dp0\signal_v1"` 가 보장 |
|
||||
| 향후 web-ai 레벨 외부 호출 (e.g. agent-office → web-ai :8000) | V1 main_server.py 는 port 8000 유지. URL 변경 없음. |
|
||||
| signal_v2 진입점이 signal_v1 의 IPC 와 충돌 | Phase 2 가 별도 port :8001 + 별도 디렉토리. IPC SharedMemory 이름 분리 (V1 의 `web_ai_bot_ipc` 그대로 유지, V2 는 IPC 사용 안 함) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 / 검증
|
||||
|
||||
### 6.1 자동
|
||||
|
||||
```bash
|
||||
# V1 테스트 전체 통과
|
||||
cd /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||
python -m pytest tests/unit -q
|
||||
# Expected: 기존 PASS 개수 그대로
|
||||
|
||||
# stock-lab → stock 의 잔여 참조 패턴 검증과 동일 — V1 안에서 import 회귀 없음
|
||||
grep -rn "from web-ai" /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||
# Expected: 0 lines (없어야 함)
|
||||
```
|
||||
|
||||
### 6.2 수동
|
||||
|
||||
- `cd web-ai && start.bat` (또는 `cd web-ai/signal_v1 && python main_server.py`)
|
||||
- 콘솔 로그에 KIS 인증 성공 / Telegram Bot connected / Ollama 모델 로드 확인
|
||||
- Telegram /status 명령 → 정상 응답
|
||||
- 30분 관측 후 Watchdog 정상 (자식 프로세스 healthy)
|
||||
|
||||
---
|
||||
|
||||
## 7. 운영 영향
|
||||
|
||||
| 항목 | 영향 |
|
||||
|------|------|
|
||||
| 다운타임 | 작업 시간 + 첫 시작 검증 = ~30분 |
|
||||
| 사용자 영향 | V1 자동매매 봇 일시 중단 (장외 시간대 진행 권장) |
|
||||
| `.env` 갱신 | 없음 (위치 그대로, 진입점만 명시 경로 변경) |
|
||||
| frontend 영향 | 없음 |
|
||||
| 다른 lab / web-backend | 없음 (web-ai 외부 의존 0) |
|
||||
| Gitea push | web-ai repo 만 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 완료 조건 (DoD)
|
||||
|
||||
- [ ] `web-ai/signal_v1/` 디렉토리 신설 + 매트릭스의 모든 V1 자산 mv 완료 (git history 보존)
|
||||
- [ ] `web-ai/CLAUDE.md` 신규 (web-ai 레벨 가이드)
|
||||
- [ ] `web-ai/start.bat` 신규 (signal_v1 cd 후 main_server.py)
|
||||
- [ ] `signal_v1/main_server.py`, `warmup_and_restart.py` 등의 `load_dotenv()` 가 `web-ai/.env` 를 명시 로드
|
||||
- [ ] `signal_v1/tests/unit/` 전체 pytest 통과 (기존 baseline 그대로)
|
||||
- [ ] `cd web-ai && start.bat` 으로 V1 봇 정상 시작 + Telegram /status 응답
|
||||
- [ ] grep `from web-ai\.` 또는 `from web-ai/` 결과 0 lines
|
||||
- [ ] web-ai repo push 완료 (단일 commit)
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase 2 와의 관계
|
||||
|
||||
본 리네이밍 완료 후 즉시 **Phase 2 brainstorming 재개**. Phase 2 spec 은:
|
||||
- 새 이름 `web-ai/signal_v2/` 기준
|
||||
- Phase 2 의 모든 결정 (배치 = 별도 FastAPI app :8001 / scope = 3 항목 / scheduler = asyncio cron / client = httpx + 자체 retry / rate limit = SQLite / test = pytest-asyncio) 그대로 반영
|
||||
- 디자인 섹션 1 (목표/scope) + 섹션 2 (파일 구조 = web-ai/signal_v2/) 의 검토 완료 상태에서 섹션 3-7 진행
|
||||
|
||||
```
|
||||
[본 리네이밍 spec/plan/실행] → [Phase 2 spec 작성 재개]
|
||||
~30분-1시간 ~15분 (남은 섹션)
|
||||
```
|
||||
2578
package-lock.json
generated
2578
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -10,9 +10,12 @@
|
||||
"deploy:nas": "node scripts/deploy-nas.cjs",
|
||||
"release:nas": "npm run build && npm run deploy:nas",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -24,6 +27,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
@@ -31,7 +37,9 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,9 @@ if (!fs.existsSync(src)) {
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
// dstWin을 PowerShell 문자열로 안전하게 escape
|
||||
const dstPs = dstWin.replace(/\\/g, "\\\\");
|
||||
// PowerShell single-quote literal로 path 전달 — backslash over-escape 회피
|
||||
const cmd =
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"${dstPs}\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS 경로를 찾을 수 없음: $dst — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $src='dist'; $dst='${dstWin}'; if(!(Test-Path $src)){ throw 'dist not found. Run build first.' }; if(!(Test-Path $dst)){ throw ('NAS 경로를 찾을 수 없음: ' + $dst + ' — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인') }; $log = Join-Path (Get-Location) 'robocopy.log'; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host ('robocopy failed with code ' + $rc + '. See ' + $log); exit $rc } else { exit 0 }"`;
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
} else if (isMac) {
|
||||
const sshTarget = process.env.NAS_SSH_TARGET;
|
||||
|
||||
116
src/api.js
116
src/api.js
@@ -479,113 +479,69 @@ export function deleteBlogPost(id) {
|
||||
return apiDelete(`/api/blog/posts/${id}`);
|
||||
}
|
||||
|
||||
// ── 블로그 마케팅 API ────────────────────────────────────────────────────────
|
||||
// ── insta-lab ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getBlogMarketingStatus() {
|
||||
return apiGet('/api/blog-marketing/status');
|
||||
export function getInstaStatus() {
|
||||
return apiGet('/api/insta/status');
|
||||
}
|
||||
|
||||
export function startResearch(keyword) {
|
||||
return apiPost('/api/blog-marketing/research', { keyword });
|
||||
export function instaCollectNews(categories) {
|
||||
return apiPost('/api/insta/news/collect', categories ? { categories } : {});
|
||||
}
|
||||
|
||||
export function getResearchHistory(limit = 30) {
|
||||
return apiGet(`/api/blog-marketing/research/history?limit=${limit}`);
|
||||
export function getInstaArticles({ category, days = 7 } = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (category) q.set('category', category);
|
||||
q.set('days', String(days));
|
||||
return apiGet(`/api/insta/news/articles?${q.toString()}`);
|
||||
}
|
||||
|
||||
export function getResearchDetail(id) {
|
||||
return apiGet(`/api/blog-marketing/research/${id}`);
|
||||
export function instaExtractKeywords(categories) {
|
||||
return apiPost('/api/insta/keywords/extract', categories ? { categories } : {});
|
||||
}
|
||||
|
||||
export function deleteResearch(id) {
|
||||
return apiDelete(`/api/blog-marketing/research/${id}`);
|
||||
export function getInstaKeywords({ category, used } = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (category) q.set('category', category);
|
||||
if (used !== undefined) q.set('used', used ? 'true' : 'false');
|
||||
const qs = q.toString();
|
||||
return apiGet(`/api/insta/keywords${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
export function getBlogMarketingTask(taskId) {
|
||||
return apiGet(`/api/blog-marketing/task/${encodeURIComponent(taskId)}`);
|
||||
export function createInstaSlate({ keyword, category, keyword_id }) {
|
||||
return apiPost('/api/insta/slates', { keyword, category, keyword_id });
|
||||
}
|
||||
|
||||
export function startGenerate(keywordId) {
|
||||
return apiPost('/api/blog-marketing/generate', { keyword_id: keywordId });
|
||||
export function getInstaSlates(limit = 50) {
|
||||
return apiGet(`/api/insta/slates?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function startReview(postId) {
|
||||
return apiPost(`/api/blog-marketing/review/${postId}`);
|
||||
export function getInstaSlate(id) {
|
||||
return apiGet(`/api/insta/slates/${id}`);
|
||||
}
|
||||
|
||||
export function startRegenerate(postId) {
|
||||
return apiPost(`/api/blog-marketing/regenerate/${postId}`);
|
||||
export function renderInstaSlate(id) {
|
||||
return apiPost(`/api/insta/slates/${id}/render`);
|
||||
}
|
||||
|
||||
export function getBlogMarketingPosts(status, limit = 50) {
|
||||
const qs = new URLSearchParams();
|
||||
if (status) qs.set('status', status);
|
||||
if (limit) qs.set('limit', String(limit));
|
||||
const q = qs.toString();
|
||||
return apiGet(`/api/blog-marketing/posts${q ? '?' + q : ''}`);
|
||||
export function deleteInstaSlate(id) {
|
||||
return apiDelete(`/api/insta/slates/${id}`);
|
||||
}
|
||||
|
||||
export function getBlogMarketingPost(id) {
|
||||
return apiGet(`/api/blog-marketing/posts/${id}`);
|
||||
export function getInstaAssetUrl(slateId, page) {
|
||||
return `/api/insta/slates/${slateId}/assets/${page}`;
|
||||
}
|
||||
|
||||
export function updateBlogMarketingPost(id, data) {
|
||||
return apiPut(`/api/blog-marketing/posts/${id}`, data);
|
||||
export function getInstaTask(taskId) {
|
||||
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
export function deleteBlogMarketingPost(id) {
|
||||
return apiDelete(`/api/blog-marketing/posts/${id}`);
|
||||
export function getInstaPrompt(name) {
|
||||
return apiGet(`/api/insta/templates/prompts/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export function publishBlogMarketingPost(id, naverUrl) {
|
||||
return apiPost(`/api/blog-marketing/posts/${id}/publish`, { naver_url: naverUrl || '' });
|
||||
}
|
||||
|
||||
export function getBlogMarketingCommissions(postId) {
|
||||
const qs = postId ? `?post_id=${postId}` : '';
|
||||
return apiGet(`/api/blog-marketing/commissions${qs}`);
|
||||
}
|
||||
|
||||
export function addBlogMarketingCommission(data) {
|
||||
return apiPost('/api/blog-marketing/commissions', data);
|
||||
}
|
||||
|
||||
export function updateBlogMarketingCommission(id, data) {
|
||||
return apiPut(`/api/blog-marketing/commissions/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteBlogMarketingCommission(id) {
|
||||
return apiDelete(`/api/blog-marketing/commissions/${id}`);
|
||||
}
|
||||
|
||||
export function getBlogMarketingDashboard() {
|
||||
return apiGet('/api/blog-marketing/dashboard');
|
||||
}
|
||||
|
||||
// 마케터 단계
|
||||
export function startMarket(postId) {
|
||||
return apiPost(`/api/blog-marketing/market/${postId}`);
|
||||
}
|
||||
|
||||
// 브랜드커넥트 링크 CRUD
|
||||
export function getBrandLinks(params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.post_id) qs.set('post_id', String(params.post_id));
|
||||
if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id));
|
||||
const q = qs.toString();
|
||||
return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`);
|
||||
}
|
||||
|
||||
export function createBrandLink(data) {
|
||||
return apiPost('/api/blog-marketing/links', data);
|
||||
}
|
||||
|
||||
export function updateBrandLink(id, data) {
|
||||
return apiPut(`/api/blog-marketing/links/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteBrandLink(id) {
|
||||
return apiDelete(`/api/blog-marketing/links/${id}`);
|
||||
export function putInstaPrompt(name, template, description = '') {
|
||||
return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description });
|
||||
}
|
||||
|
||||
// ── Agent Office ──────────────────────────────────
|
||||
|
||||
@@ -125,3 +125,12 @@ export const IconBuilding = () =>
|
||||
<rect x="11" y="16" width="3" height="3" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const IconInsta = () =>
|
||||
svg(
|
||||
<>
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" strokeWidth="0" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/* ── Blog Marketing ─────────────────────────────────────────────────────── */
|
||||
.bm { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
||||
|
||||
/* 헤더 */
|
||||
.bm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||
.bm-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
||||
.bm-status { display: flex; gap: 8px; margin-left: auto; }
|
||||
.bm-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(16,185,129,.15); color: #10b981; }
|
||||
.bm-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||
|
||||
/* 탭 바 */
|
||||
.bm-tabs { display: flex; gap: 4px; border-bottom: 1px solid rgba(255,255,255,.08); margin-bottom: 20px; }
|
||||
.bm-tab { padding: 8px 16px; font-size: 0.85rem; background: none; border: none; color: rgba(255,255,255,.45); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
|
||||
.bm-tab:hover { color: rgba(255,255,255,.7); }
|
||||
.bm-tab--active { color: #10b981; border-bottom-color: #10b981; }
|
||||
|
||||
/* ── Dashboard 탭 ─────────────────────────────────────────────────────────── */
|
||||
.bm-dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.bm-dash-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||
.bm-dash-card__label { font-size: 0.75rem; color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
||||
.bm-dash-card__value { font-size: 1.4rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
||||
.bm-dash-card__value--green { color: #10b981; }
|
||||
|
||||
.bm-dash-section { margin-bottom: 24px; }
|
||||
.bm-dash-section h3 { font-size: 0.9rem; font-weight: 600; color: rgba(255,255,255,.6); margin-bottom: 12px; }
|
||||
|
||||
.bm-top-posts { display: flex; flex-direction: column; gap: 8px; }
|
||||
.bm-top-post { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
||||
.bm-top-post__title { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bm-top-post__rev { font-size: 0.85rem; font-weight: 600; color: #10b981; margin-left: 12px; white-space: nowrap; }
|
||||
|
||||
/* ── Research 탭 ──────────────────────────────────────────────────────────── */
|
||||
.bm-research-form { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.bm-research-input { flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.9rem; outline: none; }
|
||||
.bm-research-input:focus { border-color: #10b981; }
|
||||
.bm-research-input::placeholder { color: rgba(255,255,255,.25); }
|
||||
|
||||
.bm-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.bm-btn--primary { background: #10b981; color: #fff; }
|
||||
.bm-btn--primary:hover { background: #059669; }
|
||||
.bm-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.bm-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
||||
.bm-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
||||
.bm-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||
.bm-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||
.bm-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||
|
||||
.bm-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: bm-spin .6s linear infinite; display: inline-block; }
|
||||
@keyframes bm-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 분석 카드 */
|
||||
.bm-analyses { display: flex; flex-direction: column; gap: 12px; }
|
||||
.bm-analysis-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||
.bm-analysis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.bm-analysis-card__keyword { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
||||
.bm-analysis-card__date { font-size: 0.7rem; color: rgba(255,255,255,.3); }
|
||||
.bm-analysis-card__scores { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.bm-score { text-align: center; }
|
||||
.bm-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; margin-bottom: 2px; }
|
||||
.bm-score__value { font-size: 1.1rem; font-weight: 700; }
|
||||
.bm-score__value--high { color: #10b981; }
|
||||
.bm-score__value--mid { color: #fbbf24; }
|
||||
.bm-score__value--low { color: #ef4444; }
|
||||
.bm-analysis-card__summary { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
||||
.bm-analysis-card__actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
|
||||
/* ── Write 탭 ─────────────────────────────────────────────────────────────── */
|
||||
.bm-write-empty { text-align: center; padding: 60px 20px; color: rgba(255,255,255,.3); }
|
||||
.bm-write-empty p { font-size: 0.85rem; margin-top: 8px; }
|
||||
|
||||
.bm-progress { margin-bottom: 20px; }
|
||||
.bm-progress__bar { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
||||
.bm-progress__fill { height: 100%; background: #10b981; border-radius: 2px; transition: width .3s; }
|
||||
.bm-progress__text { font-size: 0.75rem; color: rgba(255,255,255,.4); }
|
||||
|
||||
.bm-preview { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||
.bm-preview__title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
||||
.bm-preview__body { font-size: 0.85rem; color: rgba(255,255,255,.6); line-height: 1.7; max-height: 400px; overflow-y: auto; }
|
||||
.bm-preview__body h1, .bm-preview__body h2, .bm-preview__body h3 { color: var(--text-primary, #e4e4e7); margin: 16px 0 8px; }
|
||||
.bm-preview__body table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.bm-preview__body th, .bm-preview__body td { border: 1px solid rgba(255,255,255,.1); padding: 6px 10px; font-size: 0.8rem; }
|
||||
.bm-preview__body th { background: rgba(255,255,255,.06); }
|
||||
.bm-preview__tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.bm-tag { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: rgba(16,185,129,.12); color: #10b981; }
|
||||
|
||||
.bm-review-box { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
||||
.bm-review-box h4 { font-size: 0.85rem; font-weight: 600; color: var(--text-primary, #e4e4e7); margin-bottom: 10px; }
|
||||
.bm-review-scores { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.bm-review-score { text-align: center; min-width: 60px; }
|
||||
.bm-review-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; }
|
||||
.bm-review-score__val { font-size: 1rem; font-weight: 700; }
|
||||
.bm-review-total { font-size: 0.85rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.bm-review-total--pass { color: #10b981; }
|
||||
.bm-review-total--fail { color: #ef4444; }
|
||||
.bm-review-feedback { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
||||
|
||||
.bm-write-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Posts 탭 ─────────────────────────────────────────────────────────────── */
|
||||
.bm-posts-filter { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||
.bm-filter-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 0.75rem; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); cursor: pointer; transition: all .15s; }
|
||||
.bm-filter-btn--active { background: rgba(16,185,129,.15); color: #10b981; }
|
||||
|
||||
.bm-posts-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bm-post-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 14px 16px; }
|
||||
.bm-post-card__top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; }
|
||||
.bm-post-card__title { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; }
|
||||
.bm-post-card__status { font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
|
||||
.bm-post-card__status--draft { background: rgba(255,255,255,.08); color: rgba(255,255,255,.5); }
|
||||
.bm-post-card__status--reviewed { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||
.bm-post-card__status--published { background: rgba(16,185,129,.15); color: #10b981; }
|
||||
.bm-post-card__excerpt { font-size: 0.8rem; color: rgba(255,255,255,.4); margin-bottom: 8px; line-height: 1.4; }
|
||||
.bm-post-card__meta { font-size: 0.7rem; color: rgba(255,255,255,.25); display: flex; gap: 12px; }
|
||||
.bm-post-card__actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
|
||||
/* 발행 모달 */
|
||||
.bm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
||||
.bm-modal { background: #1e1e24; border: 1px solid rgba(255,255,255,.1); border-radius: 14px; padding: 24px; width: 90%; max-width: 440px; }
|
||||
.bm-modal h3 { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
||||
.bm-modal__input { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.85rem; outline: none; margin-bottom: 14px; }
|
||||
.bm-modal__input:focus { border-color: #10b981; }
|
||||
.bm-modal__buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
|
||||
/* ── 공통 빈 상태 ─────────────────────────────────────────────────────────── */
|
||||
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
|
||||
|
||||
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.bm-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bm-tabs > * {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bm { padding: 16px 10px 60px; }
|
||||
.bm-header h1 { font-size: 1.2rem; }
|
||||
.bm-status { display: none; }
|
||||
.bm-tab { padding: 6px 10px; font-size: 0.8rem; }
|
||||
.bm-dash-cards { grid-template-columns: 1fr; }
|
||||
.bm-research-form { flex-direction: column; }
|
||||
.bm-analysis-card__scores { gap: 10px; }
|
||||
.bm-write-actions { flex-direction: column; }
|
||||
.bm-post-card__actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bm-spinner { animation: none; }
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import {
|
||||
getBlogMarketingStatus,
|
||||
startResearch,
|
||||
getResearchHistory,
|
||||
getResearchDetail,
|
||||
deleteResearch,
|
||||
getBlogMarketingTask,
|
||||
startGenerate,
|
||||
startReview,
|
||||
startRegenerate,
|
||||
startMarket,
|
||||
getBlogMarketingPosts,
|
||||
getBlogMarketingPost,
|
||||
deleteBlogMarketingPost,
|
||||
publishBlogMarketingPost,
|
||||
getBlogMarketingDashboard,
|
||||
getBlogMarketingCommissions,
|
||||
addBlogMarketingCommission,
|
||||
deleteBlogMarketingCommission,
|
||||
getBrandLinks,
|
||||
createBrandLink,
|
||||
deleteBrandLink,
|
||||
} from '../../api';
|
||||
import './BlogMarketing.css';
|
||||
|
||||
/* ────────────────────── 유틸 ────────────────────── */
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function fmtMoney(n) {
|
||||
if (n == null) return '-';
|
||||
return n.toLocaleString('ko-KR') + '원';
|
||||
}
|
||||
function copyHtmlToClipboard(html) {
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const plainBlob = new Blob([html.replace(/<[^>]*>/g, '')], { type: 'text/plain' });
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({ 'text/html': blob, 'text/plain': plainBlob }),
|
||||
]).then(() => alert('본문이 클립보드에 복사되었습니다! (서식 포함)'));
|
||||
}
|
||||
|
||||
function scoreColor(v, max = 100) {
|
||||
const r = v / max;
|
||||
if (r >= 0.6) return 'bm-score__value--high';
|
||||
if (r >= 0.3) return 'bm-score__value--mid';
|
||||
return 'bm-score__value--low';
|
||||
}
|
||||
|
||||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||
function usePollTask(onDone) {
|
||||
const [taskId, setTaskId] = useState(null);
|
||||
const [task, setTask] = useState(null);
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const t = await getBlogMarketingTask(taskId);
|
||||
if (cancelled) return;
|
||||
setTask(t);
|
||||
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||
setTaskId(null);
|
||||
onDone?.(t);
|
||||
} else {
|
||||
timer.current = setTimeout(poll, 1500);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
return () => { cancelled = true; clearTimeout(timer.current); };
|
||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { taskId, task, start: setTaskId, clear: () => { setTaskId(null); setTask(null); } };
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||
export default function BlogMarketing() {
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
return getBlogMarketingStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'research', label: 'Research' },
|
||||
{ id: 'write', label: 'Write' },
|
||||
{ id: 'posts', label: 'Posts' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStatus}>
|
||||
<div className="bm">
|
||||
<header className="bm-header">
|
||||
<h1>Blog Lab</h1>
|
||||
{status && (
|
||||
<div className="bm-status">
|
||||
<span className={`bm-badge ${status.naver_api ? '' : 'bm-badge--off'}`}>
|
||||
Naver {status.naver_api ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
<span className={`bm-badge ${status.claude_api ? '' : 'bm-badge--off'}`}>
|
||||
Claude {status.claude_api ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<nav className="bm-tabs">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`bm-tab ${tab === t.id ? 'bm-tab--active' : ''}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'dashboard' && <DashboardTab />}
|
||||
{tab === 'research' && <ResearchTab />}
|
||||
{tab === 'write' && <WriteTab />}
|
||||
{tab === 'posts' && <PostsTab />}
|
||||
|
||||
<FAB onClick={() => setTab('research')} label="키워드 분석" />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */
|
||||
function DashboardTab() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getBlogMarketingDashboard().then(setData).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!data) return <div className="bm-empty">로딩 중...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bm-dash-cards">
|
||||
<DashCard label="총 포스트" value={data.total_posts} />
|
||||
<DashCard label="발행 완료" value={data.published_posts} />
|
||||
<DashCard label="총 클릭" value={data.total_clicks.toLocaleString()} />
|
||||
<DashCard label="총 수익" value={fmtMoney(data.total_revenue)} green />
|
||||
</div>
|
||||
|
||||
{data.top_posts?.length > 0 && (
|
||||
<div className="bm-dash-section">
|
||||
<h3>Top 5 포스트 (수익 기준)</h3>
|
||||
<div className="bm-top-posts">
|
||||
{data.top_posts.map(p => (
|
||||
<div key={p.id} className="bm-top-post">
|
||||
<span className="bm-top-post__title">{p.title || '(제목 없음)'}</span>
|
||||
<span className="bm-top-post__rev">{fmtMoney(p.total_revenue)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.monthly?.length > 0 && (
|
||||
<div className="bm-dash-section">
|
||||
<h3>월별 수익</h3>
|
||||
<div className="bm-top-posts">
|
||||
{data.monthly.map(m => (
|
||||
<div key={m.month} className="bm-top-post">
|
||||
<span className="bm-top-post__title">{m.month}</span>
|
||||
<span style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginRight: 12 }}>
|
||||
클릭 {m.clicks} / 구매 {m.purchases}
|
||||
</span>
|
||||
<span className="bm-top-post__rev">{fmtMoney(m.revenue)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashCard({ label, value, green }) {
|
||||
return (
|
||||
<div className="bm-dash-card">
|
||||
<div className="bm-dash-card__label">{label}</div>
|
||||
<div className={`bm-dash-card__value ${green ? 'bm-dash-card__value--green' : ''}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ Research 탭 ══════════════════════════════════════ */
|
||||
function ResearchTab() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [analyses, setAnalyses] = useState([]);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
const loadHistory = useCallback(() => {
|
||||
getResearchHistory(30).then(r => setAnalyses(r.analyses || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadHistory(); }, [loadHistory]);
|
||||
|
||||
const poll = usePollTask((t) => {
|
||||
if (t.status === 'succeeded') loadHistory();
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!keyword.trim() || poll.taskId) return;
|
||||
try {
|
||||
const { task_id } = await startResearch(keyword.trim());
|
||||
poll.start(task_id);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('이 분석을 삭제할까요?')) return;
|
||||
await deleteResearch(id);
|
||||
setAnalyses(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleGenerate = async (analysisId) => {
|
||||
try {
|
||||
const { task_id } = await startGenerate(analysisId);
|
||||
alert(`글 생성 시작! (task: ${task_id.slice(0, 8)})\nWrite 탭에서 확인하세요.`);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bm-research-form">
|
||||
<input
|
||||
className="bm-research-input"
|
||||
placeholder="분석할 키워드를 입력하세요 (예: 무선 이어폰 추천)"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
disabled={!!poll.taskId}
|
||||
/>
|
||||
<button className="bm-btn bm-btn--primary" onClick={handleSearch} disabled={!!poll.taskId}>
|
||||
{poll.taskId ? <><span className="bm-spinner" /> 분석 중...</> : '분석'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && (
|
||||
<div className="bm-progress">
|
||||
<div className="bm-progress__bar">
|
||||
<div className="bm-progress__fill" style={{ width: `${poll.task.progress || 0}%` }} />
|
||||
</div>
|
||||
<div className="bm-progress__text">{poll.task.message || '처리 중...'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bm-analyses">
|
||||
{analyses.length === 0 && !poll.taskId && (
|
||||
<div className="bm-empty">아직 분석 결과가 없습니다. 키워드를 입력해 첫 분석을 시작하세요!</div>
|
||||
)}
|
||||
{analyses.map(a => (
|
||||
<div key={a.id} className="bm-analysis-card">
|
||||
<div className="bm-analysis-card__header">
|
||||
<span className="bm-analysis-card__keyword">{a.keyword}</span>
|
||||
<span className="bm-analysis-card__date">{fmtDate(a.created_at)}</span>
|
||||
</div>
|
||||
<div className="bm-analysis-card__scores">
|
||||
<div className="bm-score">
|
||||
<span className="bm-score__label">경쟁도</span>
|
||||
<span className={`bm-score__value ${scoreColor(a.competition)}`}>{a.competition}</span>
|
||||
</div>
|
||||
<div className="bm-score">
|
||||
<span className="bm-score__label">기회</span>
|
||||
<span className={`bm-score__value ${scoreColor(a.opportunity)}`}>{a.opportunity}</span>
|
||||
</div>
|
||||
<div className="bm-score">
|
||||
<span className="bm-score__label">블로그</span>
|
||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||
{(a.blog_total || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bm-score">
|
||||
<span className="bm-score__label">쇼핑</span>
|
||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||
{(a.shop_total || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{a.avg_price != null && (
|
||||
<div className="bm-score">
|
||||
<span className="bm-score__label">평균가</span>
|
||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
||||
{fmtMoney(a.avg_price)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded === a.id && a.top_products?.length > 0 && (
|
||||
<div className="bm-analysis-card__summary">
|
||||
<strong>상위 상품:</strong>
|
||||
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||
{a.top_products.map((p, i) => (
|
||||
<li key={i}>{p.title} — {fmtMoney(p.lprice)} ({p.mallName})</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bm-analysis-card__actions">
|
||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => handleGenerate(a.id)}>
|
||||
글 생성
|
||||
</button>
|
||||
<button
|
||||
className="bm-btn bm-btn--secondary bm-btn--sm"
|
||||
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
||||
>
|
||||
{expanded === a.id ? '접기' : '상세'}
|
||||
</button>
|
||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(a.id)}>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ Write 탭 ═════════════════════════════════════════ */
|
||||
function WriteTab() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [post, setPost] = useState(null);
|
||||
|
||||
// 브랜드 링크 상태
|
||||
const [links, setLinks] = useState([]);
|
||||
const [showLinkForm, setShowLinkForm] = useState(false);
|
||||
const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
|
||||
|
||||
const loadPosts = useCallback(() => {
|
||||
Promise.all([
|
||||
getBlogMarketingPosts('draft', 20),
|
||||
getBlogMarketingPosts('marketed', 20),
|
||||
]).then(([draftRes, marketedRes]) => {
|
||||
const all = [...(draftRes.posts || []), ...(marketedRes.posts || [])];
|
||||
all.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
setPosts(all);
|
||||
if (all.length > 0 && !selected) setSelected(all[0].id);
|
||||
}).catch(() => {});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadPosts(); }, [loadPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) { setPost(null); setLinks([]); return; }
|
||||
getBlogMarketingPost(selected).then(setPost).catch(() => {});
|
||||
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
|
||||
}, [selected]);
|
||||
|
||||
const reviewPoll = usePollTask((t) => {
|
||||
if (t.status === 'succeeded' && t.result_id) {
|
||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const regenPoll = usePollTask((t) => {
|
||||
if (t.status === 'succeeded' && t.result_id) {
|
||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const marketPoll = usePollTask((t) => {
|
||||
if (t.status === 'succeeded' && t.result_id) {
|
||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
||||
loadPosts();
|
||||
}
|
||||
});
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const { task_id } = await startReview(post.id);
|
||||
reviewPoll.start(task_id);
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const { task_id } = await startRegenerate(post.id);
|
||||
regenPoll.start(task_id);
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleMarket = async () => {
|
||||
if (!post) return;
|
||||
if (links.length === 0) {
|
||||
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { task_id } = await startMarket(post.id);
|
||||
marketPoll.start(task_id);
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!post) return;
|
||||
copyHtmlToClipboard(post.body);
|
||||
};
|
||||
|
||||
const handleAddLink = async () => {
|
||||
if (!linkForm.url.trim() || !linkForm.product_name.trim()) {
|
||||
alert('URL과 상품명은 필수입니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createBrandLink({ ...linkForm, post_id: selected });
|
||||
setLinkForm({ url: '', product_name: '', description: '', placement_hint: '' });
|
||||
setShowLinkForm(false);
|
||||
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => {});
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId) => {
|
||||
if (!confirm('이 링크를 삭제할까요?')) return;
|
||||
await deleteBrandLink(linkId);
|
||||
setLinks(prev => prev.filter(l => l.id !== linkId));
|
||||
};
|
||||
|
||||
const activePoll = reviewPoll.task || regenPoll.task || marketPoll.task;
|
||||
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
|
||||
|
||||
if (posts.length === 0 && !post) {
|
||||
return (
|
||||
<div className="bm-write-empty">
|
||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>✍</div>
|
||||
<p>아직 작성 중인 글이 없습니다.<br />Research 탭에서 키워드를 분석하고 글 생성을 시작하세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{posts.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
|
||||
onClick={() => setSelected(p.id)}
|
||||
>
|
||||
{p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`}
|
||||
{p.status === 'marketed' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && activePoll && (
|
||||
<div className="bm-progress">
|
||||
<div className="bm-progress__bar">
|
||||
<div className="bm-progress__fill" style={{ width: `${activePoll.progress || 0}%` }} />
|
||||
</div>
|
||||
<div className="bm-progress__text">{activePoll.message || '처리 중...'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post && (
|
||||
<>
|
||||
{/* 브랜드커넥트 링크 섹션 */}
|
||||
<div className="bm-links-section" style={{ marginBottom: 16, padding: 12, background: 'rgba(255,255,255,0.04)', borderRadius: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h4 style={{ margin: 0, fontSize: '0.9rem' }}>브랜드커넥트 링크 ({links.length})</h4>
|
||||
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => setShowLinkForm(!showLinkForm)}>
|
||||
{showLinkForm ? '취소' : '+ 링크 추가'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showLinkForm && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12, padding: 12, background: 'rgba(0,0,0,0.2)', borderRadius: 6 }}>
|
||||
<input
|
||||
className="bm-research-input"
|
||||
placeholder="제휴 링크 URL (필수)"
|
||||
value={linkForm.url}
|
||||
onChange={e => setLinkForm(p => ({ ...p, url: e.target.value }))}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
<input
|
||||
className="bm-research-input"
|
||||
placeholder="상품명 (필수)"
|
||||
value={linkForm.product_name}
|
||||
onChange={e => setLinkForm(p => ({ ...p, product_name: e.target.value }))}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
<input
|
||||
className="bm-research-input"
|
||||
placeholder="상품 설명 (선택)"
|
||||
value={linkForm.description}
|
||||
onChange={e => setLinkForm(p => ({ ...p, description: e.target.value }))}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
<input
|
||||
className="bm-research-input"
|
||||
placeholder="배치 힌트 (선택, 예: 본문 중간 자연스럽게)"
|
||||
value={linkForm.placement_hint}
|
||||
onChange={e => setLinkForm(p => ({ ...p, placement_hint: e.target.value }))}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={handleAddLink}>등록</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map(l => (
|
||||
<div key={l.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'rgba(255,255,255,0.03)', borderRadius: 4, fontSize: '0.8rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong>{l.product_name}</strong>
|
||||
{l.description && <span style={{ marginLeft: 8, color: 'rgba(255,255,255,.4)' }}>{l.description}</span>}
|
||||
</div>
|
||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDeleteLink(l.id)} style={{ fontSize: '0.7rem', padding: '2px 6px' }}>삭제</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bm-preview">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div>
|
||||
<span className={`bm-post-card__status bm-post-card__status--${post.status}`} style={{ fontSize: '0.75rem' }}>
|
||||
{post.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
||||
{post.tags?.length > 0 && (
|
||||
<div className="bm-preview__tags">
|
||||
{post.tags.map((t, i) => <span key={i} className="bm-tag">#{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.review_detail && post.review_score != null && (
|
||||
<div className="bm-review-box">
|
||||
<h4>품질 리뷰 결과</h4>
|
||||
<div className="bm-review-scores">
|
||||
{Object.entries(post.review_detail.scores || {}).map(([k, v]) => (
|
||||
<div key={k} className="bm-review-score">
|
||||
<span className="bm-review-score__label">{k}</span>
|
||||
<span className={`bm-review-score__val ${scoreColor(v, 10)}`}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}>
|
||||
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
|
||||
</div>
|
||||
{post.review_detail.feedback && (
|
||||
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bm-write-actions">
|
||||
{post.status === 'draft' && (
|
||||
<button className="bm-btn bm-btn--primary" onClick={handleMarket} disabled={isProcessing} title={links.length === 0 ? '브랜드 링크를 먼저 추가하세요' : ''}>
|
||||
{marketPoll.taskId ? <><span className="bm-spinner" /> 마케팅 중...</> : '마케터 실행'}
|
||||
</button>
|
||||
)}
|
||||
<button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
|
||||
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 중...</> : '품질 리뷰'}
|
||||
</button>
|
||||
<button className="bm-btn bm-btn--secondary" onClick={handleRegenerate} disabled={isProcessing}>
|
||||
{regenPoll.taskId ? <><span className="bm-spinner" /> 재생성 중...</> : '재생성'}
|
||||
</button>
|
||||
<button className="bm-btn bm-btn--secondary" onClick={handleCopy}>
|
||||
본문 복사
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ Posts 탭 ═════════════════════════════════════════ */
|
||||
function PostsTab() {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [publishModal, setPublishModal] = useState(null);
|
||||
const [naverUrl, setNaverUrl] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
getBlogMarketingPosts(filter || undefined).then(r => setPosts(r.posts || [])).catch(() => {});
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('이 포스트를 삭제할까요?')) return;
|
||||
await deleteBlogMarketingPost(id);
|
||||
setPosts(prev => prev.filter(p => p.id !== id));
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!publishModal) return;
|
||||
await publishBlogMarketingPost(publishModal, naverUrl);
|
||||
setPublishModal(null);
|
||||
setNaverUrl('');
|
||||
load();
|
||||
};
|
||||
|
||||
const handleCopy = (body) => {
|
||||
copyHtmlToClipboard(body);
|
||||
};
|
||||
|
||||
const filters = [
|
||||
{ id: '', label: '전체' },
|
||||
{ id: 'draft', label: 'Draft' },
|
||||
{ id: 'marketed', label: 'Marketed' },
|
||||
{ id: 'reviewed', label: 'Reviewed' },
|
||||
{ id: 'published', label: 'Published' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bm-posts-filter">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`bm-filter-btn ${filter === f.id ? 'bm-filter-btn--active' : ''}`}
|
||||
onClick={() => setFilter(f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bm-posts-list">
|
||||
{posts.length === 0 && <div className="bm-empty">포스트가 없습니다.</div>}
|
||||
{posts.map(p => (
|
||||
<div key={p.id} className="bm-post-card">
|
||||
<div className="bm-post-card__top">
|
||||
<span className="bm-post-card__title">{p.title || '(제목 없음)'}</span>
|
||||
<span className={`bm-post-card__status bm-post-card__status--${p.status}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
|
||||
<div className="bm-post-card__meta">
|
||||
{p.review_score != null && <span>리뷰: {p.review_score}/60</span>}
|
||||
{p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
|
||||
<span>{fmtDate(p.created_at)}</span>
|
||||
</div>
|
||||
<div className="bm-post-card__actions">
|
||||
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => handleCopy(p.body)}>복사</button>
|
||||
{p.status !== 'published' && (
|
||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => { setPublishModal(p.id); setNaverUrl(''); }}>
|
||||
발행
|
||||
</button>
|
||||
)}
|
||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{publishModal && (
|
||||
<div className="bm-modal-overlay" onClick={() => setPublishModal(null)}>
|
||||
<div className="bm-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>네이버 블로그 발행</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginBottom: 12 }}>
|
||||
본문을 네이버 블로그에 붙여넣기한 후, 발행된 URL을 입력하세요.
|
||||
</p>
|
||||
<input
|
||||
className="bm-modal__input"
|
||||
placeholder="https://blog.naver.com/..."
|
||||
value={naverUrl}
|
||||
onChange={e => setNaverUrl(e.target.value)}
|
||||
/>
|
||||
<div className="bm-modal__buttons">
|
||||
<button className="bm-btn bm-btn--secondary" onClick={() => setPublishModal(null)}>취소</button>
|
||||
<button className="bm-btn bm-btn--primary" onClick={handlePublish}>발행 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/pages/insta/InstaCards.css
Normal file
102
src/pages/insta/InstaCards.css
Normal file
@@ -0,0 +1,102 @@
|
||||
/* ── InstaCards ──────────────────────────────────────────────────────────── */
|
||||
.ic { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
||||
|
||||
/* 헤더 */
|
||||
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||
.ic-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
||||
.ic-status-badges { display: flex; gap: 8px; margin-left: auto; }
|
||||
.ic-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(236,72,153,.15); color: #ec4899; }
|
||||
.ic-badge--on { background: rgba(16,185,129,.15); color: #10b981; }
|
||||
.ic-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||
|
||||
/* 버튼 공통 */
|
||||
.ic-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.ic-btn--primary { background: #ec4899; color: #fff; }
|
||||
.ic-btn--primary:hover { background: #db2777; }
|
||||
.ic-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.ic-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
||||
.ic-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
||||
.ic-btn--secondary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||
|
||||
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
|
||||
@keyframes ic-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
|
||||
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
||||
@media (min-width: 768px) {
|
||||
.ic-layout { grid-template-columns: 320px 1fr; }
|
||||
}
|
||||
|
||||
/* 섹션 카드 */
|
||||
.ic-section { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||
.ic-section__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.6); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 14px; }
|
||||
|
||||
/* 트리거 패널 */
|
||||
.ic-trigger-buttons { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ic-task-status { margin-top: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; font-size: 0.8rem; }
|
||||
.ic-task-status__label { color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
||||
.ic-task-status__msg { color: var(--text-primary, #e4e4e7); }
|
||||
.ic-task-status__progress { margin-top: 6px; height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; }
|
||||
.ic-task-status__fill { height: 100%; background: #ec4899; border-radius: 2px; transition: width .3s; }
|
||||
|
||||
/* 카테고리 필터 */
|
||||
.ic-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ic-filter-btn { padding: 4px 12px; border-radius: 99px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: rgba(255,255,255,.5); font-size: 0.75rem; cursor: pointer; transition: all .15s; }
|
||||
.ic-filter-btn--active { background: rgba(236,72,153,.18); border-color: #ec4899; color: #ec4899; }
|
||||
|
||||
/* 키워드 목록 */
|
||||
.ic-keywords { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ic-keyword-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
||||
.ic-keyword-row__kw { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
|
||||
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
|
||||
|
||||
/* 슬레이트 그리드 */
|
||||
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
|
||||
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
|
||||
.ic-slate-card--active { border-color: #ec4899; }
|
||||
.ic-slate-thumb { width: 100%; aspect-ratio: 4/5; object-fit: cover; background: rgba(255,255,255,.06); display: block; }
|
||||
.ic-slate-thumb--placeholder { width: 100%; aspect-ratio: 4/5; background: rgba(255,255,255,.04); display: flex; align-items: center; justify-content: center; font-size: 1.8rem; }
|
||||
.ic-slate-card__info { padding: 8px; }
|
||||
.ic-slate-card__kw { font-size: 0.78rem; font-weight: 600; color: var(--text-primary, #e4e4e7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ic-slate-card__meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||
.ic-slate-card__date { font-size: 0.65rem; color: rgba(255,255,255,.3); }
|
||||
|
||||
/* 상태 뱃지 */
|
||||
.ic-status-badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 99px; font-weight: 600; }
|
||||
.ic-status-badge--draft { background: rgba(161,161,170,.15); color: #a1a1aa; }
|
||||
.ic-status-badge--rendered { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
|
||||
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||
|
||||
/* 슬레이트 상세 패널 */
|
||||
.ic-detail { margin-top: 20px; padding: 16px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; }
|
||||
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; }
|
||||
.ic-detail__actions { display: flex; gap: 8px; }
|
||||
|
||||
.ic-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
|
||||
.ic-page-img { width: 120px; flex-shrink: 0; aspect-ratio: 4/5; border-radius: 6px; object-fit: cover; scroll-snap-align: start; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
|
||||
|
||||
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
|
||||
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
|
||||
.ic-caption-text { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||
.ic-hashtags { font-size: 0.8rem; color: #60a5fa; line-height: 1.8; word-break: break-all; }
|
||||
|
||||
/* 프롬프트 에디터 */
|
||||
.ic-prompt-editor { margin-top: 20px; }
|
||||
.ic-prompt-editor__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.5); margin-bottom: 12px; text-transform: uppercase; }
|
||||
.ic-prompt-block { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
.ic-prompt-block__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.ic-prompt-block__name { font-size: 0.8rem; font-weight: 700; color: rgba(255,255,255,.7); flex: 1; }
|
||||
.ic-prompt-block__date { font-size: 0.68rem; color: rgba(255,255,255,.3); }
|
||||
.ic-prompt-textarea { width: 100%; min-height: 140px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: var(--text-primary, #e4e4e7); font-size: 0.8rem; font-family: monospace; line-height: 1.5; padding: 10px; resize: vertical; box-sizing: border-box; outline: none; }
|
||||
.ic-prompt-textarea:focus { border-color: #ec4899; }
|
||||
.ic-prompt-save-row { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||
|
||||
/* 빈 상태 */
|
||||
.ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; }
|
||||
525
src/pages/insta/InstaCards.jsx
Normal file
525
src/pages/insta/InstaCards.jsx
Normal file
@@ -0,0 +1,525 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import {
|
||||
getInstaStatus,
|
||||
instaCollectNews,
|
||||
instaExtractKeywords,
|
||||
getInstaKeywords,
|
||||
createInstaSlate,
|
||||
getInstaSlates,
|
||||
getInstaSlate,
|
||||
renderInstaSlate,
|
||||
deleteInstaSlate,
|
||||
getInstaAssetUrl,
|
||||
getInstaTask,
|
||||
getInstaPrompt,
|
||||
putInstaPrompt,
|
||||
} from '../../api';
|
||||
import './InstaCards.css';
|
||||
|
||||
/* ────────────────────── 유틸 ────────────────────── */
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
return (
|
||||
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
|
||||
{status || 'draft'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||
function usePollTask(onDone) {
|
||||
const [taskId, setTaskId] = useState(null);
|
||||
const [task, setTask] = useState(null);
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const t = await getInstaTask(taskId);
|
||||
if (cancelled) return;
|
||||
setTask(t);
|
||||
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||
setTaskId(null);
|
||||
onDone?.(t);
|
||||
} else {
|
||||
timer.current = setTimeout(poll, 3000);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer.current);
|
||||
};
|
||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
taskId,
|
||||
task,
|
||||
start: setTaskId,
|
||||
clear: () => { setTaskId(null); setTask(null); },
|
||||
};
|
||||
}
|
||||
|
||||
/* ────────────────────── TaskStatusBox ────────────────────── */
|
||||
function TaskStatusBox({ task }) {
|
||||
if (!task) return null;
|
||||
const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0);
|
||||
return (
|
||||
<div className="ic-task-status">
|
||||
<div className="ic-task-status__label">
|
||||
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
|
||||
</div>
|
||||
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
|
||||
<div className="ic-task-status__progress">
|
||||
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
||||
export default function InstaCards() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [selectedSlateId, setSelectedSlateId] = useState(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
return getInstaStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadStatus}>
|
||||
<div className="ic">
|
||||
{/* 헤더 + 상태 배너 */}
|
||||
<header className="ic-header">
|
||||
<h1>Insta Cards</h1>
|
||||
{status && (
|
||||
<div className="ic-status-badges">
|
||||
<span className={`ic-badge ${status.naver_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
|
||||
Naver {status.naver_api ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
<span className={`ic-badge ${status.anthropic_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
|
||||
AI {status.anthropic_api ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="ic-layout">
|
||||
{/* 왼쪽: 트리거 + 키워드 */}
|
||||
<div>
|
||||
<TriggerPanel />
|
||||
<div style={{ height: 16 }} />
|
||||
<KeywordsPanel onCreateSlate={() => setSelectedSlateId(null)} />
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 슬레이트 목록 + 상세 */}
|
||||
<div>
|
||||
<SlatesPanel
|
||||
selectedId={selectedSlateId}
|
||||
onSelect={setSelectedSlateId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptTemplatesEditor />
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
||||
function TriggerPanel() {
|
||||
const collectPoll = usePollTask();
|
||||
const keywordsPoll = usePollTask();
|
||||
|
||||
async function handleCollect() {
|
||||
try {
|
||||
const res = await instaCollectNews();
|
||||
collectPoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('뉴스 수집 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeywords() {
|
||||
try {
|
||||
const res = await instaExtractKeywords();
|
||||
keywordsPoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('키워드 추출 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const collectBusy = !!collectPoll.taskId;
|
||||
const kwBusy = !!keywordsPoll.taskId;
|
||||
|
||||
return (
|
||||
<div className="ic-section">
|
||||
<p className="ic-section__title">트리거</p>
|
||||
<div className="ic-trigger-buttons">
|
||||
<button
|
||||
className="ic-btn ic-btn--primary"
|
||||
onClick={handleCollect}
|
||||
disabled={collectBusy}
|
||||
>
|
||||
{collectBusy && <span className="ic-spinner" />}
|
||||
뉴스 수집
|
||||
</button>
|
||||
<TaskStatusBox task={collectPoll.task} />
|
||||
<button
|
||||
className="ic-btn ic-btn--secondary"
|
||||
onClick={handleKeywords}
|
||||
disabled={kwBusy}
|
||||
>
|
||||
{kwBusy && <span className="ic-spinner" />}
|
||||
키워드 추출
|
||||
</button>
|
||||
<TaskStatusBox task={keywordsPoll.task} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
|
||||
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
|
||||
|
||||
function KeywordsPanel({ onCreateSlate }) {
|
||||
const [category, setCategory] = useState('전체');
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const [creating, setCreating] = useState(null); // keyword_id being created
|
||||
const slatePoll = usePollTask((t) => {
|
||||
if (t.status === 'succeeded') onCreateSlate?.();
|
||||
setCreating(null);
|
||||
});
|
||||
|
||||
const load = useCallback(() => {
|
||||
const cat = category === '전체' ? undefined : category;
|
||||
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleCreate(kw) {
|
||||
if (creating) return;
|
||||
setCreating(kw.id);
|
||||
try {
|
||||
const res = await createInstaSlate({
|
||||
keyword: kw.keyword,
|
||||
category: kw.category,
|
||||
keyword_id: kw.id,
|
||||
});
|
||||
slatePoll.start(res.task_id);
|
||||
} catch (e) {
|
||||
alert('카드 생성 실패: ' + e.message);
|
||||
setCreating(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-section">
|
||||
<p className="ic-section__title">트렌딩 키워드</p>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="ic-filter">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{slatePoll.task && <TaskStatusBox task={slatePoll.task} />}
|
||||
|
||||
{keywords.length === 0 ? (
|
||||
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
||||
) : (
|
||||
<div className="ic-keywords">
|
||||
{keywords.map((kw) => (
|
||||
<div key={kw.id} className="ic-keyword-row">
|
||||
<span className="ic-keyword-row__kw">{kw.keyword}</span>
|
||||
<span className="ic-keyword-row__meta">
|
||||
{kw.category} · {kw.articles_count ?? 0}건
|
||||
</span>
|
||||
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
|
||||
<button
|
||||
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||
onClick={() => handleCreate(kw)}
|
||||
disabled={!!creating}
|
||||
>
|
||||
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
||||
function SlatesPanel({ selectedId, onSelect }) {
|
||||
const [slates, setSlates] = useState([]);
|
||||
const [detail, setDetail] = useState(null);
|
||||
|
||||
const loadSlates = useCallback(() => {
|
||||
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadSlates(); }, [loadSlates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setDetail(null); return; }
|
||||
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
|
||||
}, [selectedId]);
|
||||
|
||||
function handleSelect(id) {
|
||||
onSelect(id === selectedId ? null : id);
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await deleteInstaSlate(id);
|
||||
if (selectedId === id) onSelect(null);
|
||||
loadSlates();
|
||||
} catch (e) {
|
||||
alert('삭제 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRender(id) {
|
||||
try {
|
||||
const res = await renderInstaSlate(id);
|
||||
// Re-render is fire-and-forget from the panel; user can refresh detail
|
||||
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
|
||||
setTimeout(loadSlates, 3000);
|
||||
} catch (e) {
|
||||
alert('재렌더 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ic-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
||||
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
||||
</div>
|
||||
|
||||
{slates.length === 0 ? (
|
||||
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
||||
) : (
|
||||
<div className="ic-slates-grid">
|
||||
{slates.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
||||
onClick={() => handleSelect(s.id)}
|
||||
>
|
||||
{s.status === 'rendered' || s.status === 'sent' ? (
|
||||
<img
|
||||
className="ic-slate-thumb"
|
||||
src={getInstaAssetUrl(s.id, 1)}
|
||||
alt={s.keyword}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="ic-slate-thumb--placeholder">🎴</div>
|
||||
)}
|
||||
<div className="ic-slate-card__info">
|
||||
<div className="ic-slate-card__kw">{s.keyword}</div>
|
||||
<div className="ic-slate-card__meta">
|
||||
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 슬레이트 상세 */}
|
||||
{detail && (
|
||||
<SlateDetail
|
||||
slate={detail}
|
||||
onDelete={() => handleDelete(detail.id)}
|
||||
onRender={() => handleRender(detail.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||
function SlateDetail({ slate, onDelete, onRender }) {
|
||||
const pages = slate.assets || [];
|
||||
const pageCount = pages.length > 0 ? pages.length : 10;
|
||||
|
||||
function copyCaption() {
|
||||
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
||||
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-detail">
|
||||
<div className="ic-detail__header">
|
||||
<div className="ic-detail__title">
|
||||
{slate.keyword}
|
||||
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
||||
</div>
|
||||
<div className="ic-detail__actions">
|
||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||||
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지 이미지 스트립 */}
|
||||
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||
<div className="ic-pages-strip">
|
||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||
<img
|
||||
key={page}
|
||||
className="ic-page-img"
|
||||
src={getInstaAssetUrl(slate.id, page)}
|
||||
alt={`Page ${page}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||||
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 캡션 */}
|
||||
{slate.suggested_caption && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">
|
||||
캡션
|
||||
<button
|
||||
className="ic-btn ic-btn--secondary ic-btn--sm"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={copyCaption}
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
||||
{slate.hashtags?.length > 0 && (
|
||||
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
||||
{slate.hashtags.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 커버 카피 / 바디 카피 */}
|
||||
{slate.cover_copy && (
|
||||
<div className="ic-caption-box">
|
||||
<div className="ic-caption-box__label">커버 카피</div>
|
||||
<div className="ic-caption-text">{slate.cover_copy}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
|
||||
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
|
||||
|
||||
function PromptTemplatesEditor() {
|
||||
const [prompts, setPrompts] = useState({});
|
||||
const [drafts, setDrafts] = useState({});
|
||||
const [saving, setSaving] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
PROMPT_NAMES.forEach((name) => {
|
||||
getInstaPrompt(name)
|
||||
.then((p) => {
|
||||
setPrompts((prev) => ({ ...prev, [name]: p }));
|
||||
setDrafts((prev) => ({ ...prev, [name]: p.template }));
|
||||
})
|
||||
.catch(() => {
|
||||
setPrompts((prev) => ({ ...prev, [name]: null }));
|
||||
setDrafts((prev) => ({ ...prev, [name]: '' }));
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleSave(name) {
|
||||
setSaving((prev) => ({ ...prev, [name]: true }));
|
||||
try {
|
||||
const updated = await putInstaPrompt(name, drafts[name] || '', prompts[name]?.description || '');
|
||||
setPrompts((prev) => ({ ...prev, [name]: updated }));
|
||||
alert(`${name} 저장 완료`);
|
||||
} catch (e) {
|
||||
alert('저장 실패: ' + e.message);
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [name]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ic-prompt-editor" style={{ marginTop: 24 }}>
|
||||
<p className="ic-prompt-editor__title">프롬프트 템플릿</p>
|
||||
{PROMPT_NAMES.map((name) => (
|
||||
<div key={name} className="ic-prompt-block">
|
||||
<div className="ic-prompt-block__head">
|
||||
<span className="ic-prompt-block__name">{name}</span>
|
||||
{prompts[name]?.updated_at && (
|
||||
<span className="ic-prompt-block__date">
|
||||
최종 수정: {fmtDate(prompts[name].updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prompts[name]?.description && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,.4)', marginBottom: 6 }}>
|
||||
{prompts[name].description}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="ic-prompt-textarea"
|
||||
value={drafts[name] ?? ''}
|
||||
onChange={(e) => setDrafts((prev) => ({ ...prev, [name]: e.target.value }))}
|
||||
placeholder={`${name} 템플릿을 입력하세요...`}
|
||||
/>
|
||||
<div className="ic-prompt-save-row">
|
||||
<button
|
||||
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||
onClick={() => handleSave(name)}
|
||||
disabled={saving[name]}
|
||||
>
|
||||
{saving[name] ? <span className="ic-spinner" /> : null}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@import './components/canvas/Canvas.css';
|
||||
.screener-page {
|
||||
padding: 24px;
|
||||
color: var(--text, #e5e7eb);
|
||||
@@ -80,3 +81,107 @@
|
||||
.screener-table th { text-align: left; padding: 8px; background: #0a0f1a; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; }
|
||||
.screener-table td { padding: 8px; border-bottom: 1px solid #1a2230; vertical-align: middle; }
|
||||
.screener-table tr:hover { background: #0a0f1a; }
|
||||
|
||||
/* === 결과 표 헤더 === */
|
||||
.screener-result-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.screener-warn {
|
||||
background: #7c2d12;
|
||||
color: #fde68a;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === 모바일 카드 layout === */
|
||||
.screener-mobile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.screener-mcard {
|
||||
background: #0a0f1a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.screener-mcard-head {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.screener-mcard-rank {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fbbf24;
|
||||
text-align: center;
|
||||
}
|
||||
.screener-mcard-name-main {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.screener-mcard-name-sub {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.screener-mcard-score {
|
||||
text-align: right;
|
||||
}
|
||||
.screener-mcard-score-val {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.screener-mcard-score-lbl {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.screener-mcard-delta {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
background: #0f1623;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.screener-mcard-delta span { display: flex; gap: 4px; align-items: center; }
|
||||
.screener-mcard-chips { padding: 0; }
|
||||
.screener-mcard-prices {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
.screener-mcard-prices > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.screener-mcard-prices .lbl {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
.screener-out-divider {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, lazy, Suspense } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './Screener.css';
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useScreenerMeta } from './hooks/useScreenerMeta';
|
||||
import { useScreenerSettings } from './hooks/useScreenerSettings';
|
||||
import { useScreenerRun } from './hooks/useScreenerRun';
|
||||
import { useScreenerHistory } from './hooks/useScreenerHistory';
|
||||
import { useScreenerMode } from './hooks/useScreenerMode';
|
||||
import { useIsMobile } from '../../../hooks/useIsMobile';
|
||||
|
||||
import GatePanel from './components/GatePanel';
|
||||
import NodePanel from './components/NodePanel';
|
||||
@@ -13,13 +15,22 @@ import GlobalControls from './components/GlobalControls';
|
||||
import ResultTable from './components/ResultTable';
|
||||
import TelegramPreview from './components/TelegramPreview';
|
||||
import RunHistoryList from './components/RunHistoryList';
|
||||
import ModeToggle from './components/ModeToggle';
|
||||
|
||||
const CanvasLayout = lazy(() => import('./components/canvas/CanvasLayout'));
|
||||
|
||||
export default function Screener() {
|
||||
const { meta, loading: metaLoading } = useScreenerMeta();
|
||||
const { settings, dirty, setLocal, save } = useScreenerSettings();
|
||||
const { result, running, runPreview, runSave } = useScreenerRun();
|
||||
const { settings, dirty, setLocal, save } = useScreenerSettings();
|
||||
const { result, running, previewHistory, runPreview, runSave, selectPreview } = useScreenerRun();
|
||||
const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
|
||||
const { mode, setMode } = useScreenerMode();
|
||||
const isMobile = useIsMobile();
|
||||
const effectiveMode = isMobile ? 'form' : mode;
|
||||
|
||||
const [compareId, setCompareId] = useState(null);
|
||||
const compareItem = previewHistory.find((p) => p.id === compareId);
|
||||
const compareResult = compareItem?.result ?? null;
|
||||
const activeResult = selectedRun || result;
|
||||
|
||||
if (metaLoading || !meta || !settings) {
|
||||
@@ -36,36 +47,83 @@ export default function Screener() {
|
||||
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
<Link to="/stock">시장</Link>
|
||||
<Link to="/stock/trade">트레이드</Link>
|
||||
</nav>
|
||||
<div className="screener-header-right">
|
||||
{!isMobile && <ModeToggle value={mode} onChange={setMode} />}
|
||||
<nav>
|
||||
<Link to="/stock">시장</Link>
|
||||
<Link to="/stock/trade">트레이드</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="screener-grid">
|
||||
<aside className="screener-left">
|
||||
<GatePanel meta={meta.gate_nodes[0]} value={settings.gate_params} onChange={(p) => setLocal({...settings, gate_params: p})} />
|
||||
<NodePanel meta={meta.score_nodes} weights={settings.weights} params={settings.node_params}
|
||||
onWeights={(w) => setLocal({...settings, weights: w})}
|
||||
onParams={(p) => setLocal({...settings, node_params: p})} />
|
||||
<GlobalControls settings={settings} setSettings={setLocal}
|
||||
onRun={() => runPreview(settings)}
|
||||
onSave={() => runSave(settings)}
|
||||
onPersist={save}
|
||||
dirty={dirty}
|
||||
running={running} />
|
||||
</aside>
|
||||
|
||||
<main className="screener-center">
|
||||
<ResultTable result={activeResult} />
|
||||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||
</main>
|
||||
|
||||
<aside className="screener-right">
|
||||
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
|
||||
selectedId={selectedRun?.meta?.id} />
|
||||
</aside>
|
||||
</div>
|
||||
{effectiveMode === 'form' ? (
|
||||
<div className="screener-grid">
|
||||
<aside className="screener-left">
|
||||
<GatePanel
|
||||
meta={meta.gate_nodes[0]}
|
||||
value={settings.gate_params}
|
||||
onChange={(p) => setLocal({ ...settings, gate_params: p })}
|
||||
/>
|
||||
<NodePanel
|
||||
meta={meta.score_nodes}
|
||||
weights={settings.weights}
|
||||
params={settings.node_params}
|
||||
onWeights={(w) => setLocal({ ...settings, weights: w })}
|
||||
onParams={(p) => setLocal({ ...settings, node_params: p })}
|
||||
/>
|
||||
<GlobalControls
|
||||
settings={settings} setSettings={setLocal}
|
||||
onRun={() => runPreview(settings)}
|
||||
onSave={() => runSave(settings)}
|
||||
onPersist={save}
|
||||
dirty={dirty}
|
||||
running={running}
|
||||
/>
|
||||
</aside>
|
||||
<main className="screener-center">
|
||||
<ResultTable
|
||||
result={activeResult}
|
||||
compareWith={compareResult}
|
||||
compareLabel={compareItem ? new Date(compareItem.timestamp).toLocaleTimeString() : null}
|
||||
/>
|
||||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||
</main>
|
||||
<aside className="screener-right">
|
||||
<RunHistoryList
|
||||
runs={runs}
|
||||
loading={runs_loading}
|
||||
onSelect={selectRun}
|
||||
selectedId={selectedRun?.meta?.id}
|
||||
previewHistory={previewHistory}
|
||||
onSelectPreview={selectPreview}
|
||||
onSetCompare={setCompareId}
|
||||
compareId={compareId}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<div className="screener-loading">캔버스 로딩 중…</div>}>
|
||||
<CanvasLayout
|
||||
meta={meta}
|
||||
settings={settings}
|
||||
setLocal={setLocal}
|
||||
save={save}
|
||||
dirty={dirty}
|
||||
result={result}
|
||||
running={running}
|
||||
previewHistory={previewHistory}
|
||||
runPreview={runPreview}
|
||||
runSave={runSave}
|
||||
selectPreview={selectPreview}
|
||||
runs={runs}
|
||||
runs_loading={runs_loading}
|
||||
selectRun={selectRun}
|
||||
selectedRun={selectedRun}
|
||||
compareId={compareId}
|
||||
setCompareId={setCompareId}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
26
src/pages/stock/screener/components/ModeToggle.jsx
Normal file
26
src/pages/stock/screener/components/ModeToggle.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ModeToggle({ value, onChange }) {
|
||||
return (
|
||||
<div className="screener-mode-toggle" role="tablist" aria-label="화면 모드">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === 'form'}
|
||||
className={value === 'form' ? 'active' : ''}
|
||||
onClick={() => onChange('form')}
|
||||
>
|
||||
폼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === 'canvas'}
|
||||
className={value === 'canvas' ? 'active' : ''}
|
||||
onClick={() => onChange('canvas')}
|
||||
>
|
||||
캔버스
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,231 @@
|
||||
import ScoreChips from './ScoreChips';
|
||||
import { useIsMobile } from '../../../../hooks/useIsMobile';
|
||||
|
||||
const COL_TIPS = {
|
||||
rank: '순위 — 종합 점수가 높은 순서',
|
||||
name: '종목명과 종목 코드',
|
||||
total: '종합 점수 (0~100) — 활성 점수 노드들의 가중평균. 가중치는 좌측 패널에서 조정',
|
||||
nodes: '노드별 점수 칩 — 70점 이상이면 노란색 강조. 각 칩에 마우스 올리면 해당 노드 설명이 나옵니다',
|
||||
entry: '예상 진입가 (원) — 현재 종가의 +0.5%, 다음날 시초가 슬리피지 가정',
|
||||
stop: '손절가 (원) — 현재가 - 2 × ATR(14, Wilder smoothing). 변동성 기반 손절',
|
||||
target: '익절가 (원) — 진입가 + (진입가 - 손절가) × R:R 비율 (기본 2.0). 위험 1 대비 보상 2',
|
||||
r_pct: '손실 위험 % — (진입가 - 손절가) / 진입가 × 100. 클수록 변동성 큰 종목',
|
||||
delta_rank: '비교 대상 대비 순위 변화 — ▲(상승)·▼(하락)·NEW(이번에 새로 진입)·OUT(비교 대상에만 있음)',
|
||||
delta_score: '비교 대상 대비 점수 변화 — 양수면 상승',
|
||||
};
|
||||
|
||||
function Th({ k, children }) {
|
||||
return (
|
||||
<th title={COL_TIPS[k]} style={{ cursor: 'help' }}>
|
||||
{children}
|
||||
<span style={{ marginLeft: 4, fontSize: 10, color: '#6b7280' }}>ⓘ</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompareIndex(compareWith) {
|
||||
if (!compareWith?.results) return null;
|
||||
const idx = new Map();
|
||||
for (const r of compareWith.results) idx.set(r.ticker, r);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function DeltaRank({ current, prev }) {
|
||||
if (!prev) {
|
||||
return <span style={{ color: '#22c55e', fontSize: 11, fontWeight: 600 }}>NEW</span>;
|
||||
}
|
||||
const diff = prev.rank - current.rank;
|
||||
if (diff === 0) return <span style={{ color: '#9ca3af', fontSize: 11 }}>─</span>;
|
||||
const up = diff > 0;
|
||||
return (
|
||||
<span style={{ color: up ? '#22c55e' : '#ef4444', fontSize: 11 }}>
|
||||
{up ? '▲' : '▼'} {Math.abs(diff)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaScore({ current, prev }) {
|
||||
if (!prev) return <span style={{ color: '#9ca3af', fontSize: 11 }}>-</span>;
|
||||
const d = (current.total_score ?? 0) - (prev.total_score ?? 0);
|
||||
if (Math.abs(d) < 0.1) return <span style={{ color: '#9ca3af', fontSize: 11 }}>─</span>;
|
||||
const up = d > 0;
|
||||
return (
|
||||
<span style={{ color: up ? '#22c55e' : '#ef4444', fontSize: 11 }}>
|
||||
{up ? '+' : ''}{d.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCard({ r, prev, hasCompare }) {
|
||||
return (
|
||||
<div className="screener-mcard">
|
||||
<div className="screener-mcard-head">
|
||||
<div className="screener-mcard-rank">#{r.rank}</div>
|
||||
<div className="screener-mcard-name">
|
||||
<div className="screener-mcard-name-main">{r.name}</div>
|
||||
<div className="screener-mcard-name-sub">{r.ticker}</div>
|
||||
</div>
|
||||
<div className="screener-mcard-score">
|
||||
<div className="screener-mcard-score-val">{r.total_score?.toFixed(1)}</div>
|
||||
<div className="screener-mcard-score-lbl">총점</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasCompare && (
|
||||
<div className="screener-mcard-delta">
|
||||
<span>순위 <DeltaRank current={r} prev={prev} /></span>
|
||||
<span>점수 <DeltaScore current={r} prev={prev} /></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="screener-mcard-chips">
|
||||
<ScoreChips scores={r.scores} />
|
||||
</div>
|
||||
<div className="screener-mcard-prices">
|
||||
<div><span className="lbl">진입</span><span>{r.entry_price?.toLocaleString?.()}원</span></div>
|
||||
<div><span className="lbl">손절</span><span>{r.stop_price?.toLocaleString?.()}원</span></div>
|
||||
<div><span className="lbl">익절</span><span>{r.target_price?.toLocaleString?.()}원</span></div>
|
||||
<div><span className="lbl">위험</span><span>{r.r_pct?.toFixed?.(1)}%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileOutCard({ r }) {
|
||||
return (
|
||||
<div className="screener-mcard" style={{ opacity: 0.55 }}>
|
||||
<div className="screener-mcard-head">
|
||||
<div className="screener-mcard-rank">
|
||||
<span style={{ color: '#ef4444', fontWeight: 600 }}>OUT</span>
|
||||
</div>
|
||||
<div className="screener-mcard-name">
|
||||
<div className="screener-mcard-name-main">{r.name}</div>
|
||||
<div className="screener-mcard-name-sub">{r.ticker}</div>
|
||||
</div>
|
||||
<div className="screener-mcard-score">
|
||||
<div className="screener-mcard-score-val">{r.total_score?.toFixed(1)}</div>
|
||||
<div className="screener-mcard-score-lbl">이전</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="screener-mcard-chips">
|
||||
<ScoreChips scores={r.scores} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResultTable({ result, compareWith, compareLabel }) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
export default function ResultTable({ result }) {
|
||||
if (!result) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행"을 눌러보세요.</p>
|
||||
<p style={{ color: '#6b7280', fontSize: 12, marginTop: 8 }}>
|
||||
💡 컬럼/칩에 마우스를 올리면 의미가 표시됩니다 (PC).
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const cmpIdx = buildCompareIndex(compareWith);
|
||||
const hasCompare = !!cmpIdx;
|
||||
const currentTickers = new Set((result.results || []).map((r) => r.ticker));
|
||||
const onlyInCompare = hasCompare
|
||||
? (compareWith.results || []).filter((r) => !currentTickers.has(r.ticker))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="screener-result-head">
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
|
||||
{hasCompare && (
|
||||
<span style={{ marginLeft: 8, fontSize: 12, color: '#fbbf24' }}>
|
||||
vs {compareLabel ?? '비교 대상'} (통과 {compareWith.survivors_count})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{result.warnings?.length > 0 && (
|
||||
<div style={{
|
||||
background: '#7c2d12', color: '#fde68a', padding: '4px 10px',
|
||||
borderRadius: 4, fontSize: 12,
|
||||
}}>
|
||||
<div className="screener-warn">
|
||||
⚠ {result.warnings.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||
<table className="screener-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>종목</th><th>총점</th><th>노드</th>
|
||||
<th>진입</th><th>손절</th><th>익절</th><th>R%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(result.results || []).map((r) => (
|
||||
<tr key={r.ticker}>
|
||||
<td>{r.rank}</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||
<p style={{ color: '#6b7280', fontSize: 11, marginTop: 8, marginBottom: 0 }}>
|
||||
{isMobile
|
||||
? `💡 종목 카드를 위아래로 스크롤하며 확인${hasCompare ? ' · 비교 모드 ON' : ''}`
|
||||
: `💡 컬럼/칩에 마우스를 올리면 의미가 표시됩니다${hasCompare ? ' · 비교 모드 ON — ▲▼NEW/OUT 변화 표시' : ''}`}
|
||||
</p>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="screener-mobile-list">
|
||||
{(result.results || []).map((r) => (
|
||||
<MobileCard key={r.ticker} r={r} prev={cmpIdx?.get(r.ticker)} hasCompare={hasCompare} />
|
||||
))}
|
||||
{hasCompare && onlyInCompare.length > 0 && (
|
||||
<>
|
||||
<div className="screener-out-divider">── 이번엔 빠진 종목 ──</div>
|
||||
{onlyInCompare.map((r) => <MobileOutCard key={`out-${r.ticker}`} r={r} />)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||
<table className="screener-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th k="rank">#</Th>
|
||||
<Th k="name">종목</Th>
|
||||
<Th k="total">총점</Th>
|
||||
{hasCompare && <Th k="delta_rank">순위Δ</Th>}
|
||||
{hasCompare && <Th k="delta_score">점수Δ</Th>}
|
||||
<Th k="nodes">노드</Th>
|
||||
<Th k="entry">진입(원)</Th>
|
||||
<Th k="stop">손절(원)</Th>
|
||||
<Th k="target">익절(원)</Th>
|
||||
<Th k="r_pct">R%</Th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(result.results || []).map((r) => {
|
||||
const prev = cmpIdx?.get(r.ticker);
|
||||
return (
|
||||
<tr key={r.ticker}>
|
||||
<td>{r.rank}</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
|
||||
{hasCompare && <td><DeltaRank current={r} prev={prev} /></td>}
|
||||
{hasCompare && <td><DeltaScore current={r} prev={prev} /></td>}
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{hasCompare && onlyInCompare.length > 0 && (
|
||||
<>
|
||||
<tr><td colSpan={10} style={{ fontSize: 11, color: '#6b7280', padding: '12px 8px 4px' }}>
|
||||
── 이번엔 빠진 종목 (비교 대상에만 존재) ──
|
||||
</td></tr>
|
||||
{onlyInCompare.map((r) => (
|
||||
<tr key={`out-${r.ticker}`} style={{ opacity: 0.55 }}>
|
||||
<td>—</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 500 }}>{r.total_score?.toFixed(1)}</td>
|
||||
<td><span style={{ color: '#ef4444', fontSize: 11, fontWeight: 600 }}>OUT</span></td>
|
||||
<td>—</td>
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td colSpan={4}>—</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,92 @@
|
||||
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
|
||||
if (loading) return <section className="screener-card"><p>로딩…</p></section>;
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function RunHistoryList({
|
||||
runs, loading, onSelect, selectedId,
|
||||
previewHistory = [], onSelectPreview, selectedPreviewId,
|
||||
onSetCompare, compareId,
|
||||
}) {
|
||||
const hasPreview = previewHistory.length > 0;
|
||||
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>최근 실행</h3>
|
||||
<ul style={{listStyle:'none', padding:0, margin:0, fontSize:13}}>
|
||||
{(runs || []).map((r) => (
|
||||
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
|
||||
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
|
||||
onClick={() => onSelect(r.id)}>
|
||||
{r.asof} · {r.mode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p style={{ fontSize: 11, color: '#6b7280', marginTop: 0 }}>
|
||||
💡 클릭하면 결과 표에 로드. 우측 "비교"를 누르면 다른 실행과 함께 표시
|
||||
</p>
|
||||
|
||||
{hasPreview && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 4 }}>
|
||||
이번 세션 미리보기 (새로고침 시 사라짐)
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 12 }}>
|
||||
{previewHistory.map((p) => {
|
||||
const isSelected = selectedPreviewId === p.id;
|
||||
const isCompare = compareId === p.id;
|
||||
return (
|
||||
<li key={p.id} style={{
|
||||
padding: '6px 4px',
|
||||
borderBottom: '1px solid #1f2937',
|
||||
background: isSelected ? '#1f2937' : 'transparent',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
<span
|
||||
onClick={() => onSelectPreview?.(p.id)}
|
||||
style={{ cursor: 'pointer', flex: 1, color: isSelected ? '#fbbf24' : '#e5e7eb' }}
|
||||
>
|
||||
{formatTime(p.timestamp)} · {p.mode}
|
||||
<br />
|
||||
<span style={{ fontSize: 10, color: '#9ca3af' }}>
|
||||
통과 {p.survivors_count ?? '-'} · Top1 {p.top_name ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onSetCompare?.(isCompare ? null : p.id)}
|
||||
style={{
|
||||
padding: '2px 6px', fontSize: 10,
|
||||
background: isCompare ? '#fbbf24' : '#374151',
|
||||
color: isCompare ? '#0b0f17' : '#e5e7eb',
|
||||
border: 'none', borderRadius: 4, cursor: 'pointer',
|
||||
}}
|
||||
title="이 결과를 비교 대상으로 설정"
|
||||
>
|
||||
{isCompare ? '✓ 비교중' : '비교'}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 4 }}>
|
||||
저장된 실행 (자동 잡 + 스냅샷 저장)
|
||||
</div>
|
||||
{loading ? <p style={{ fontSize: 12 }}>로딩…</p> : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 13 }}>
|
||||
{(runs || []).length === 0 && (
|
||||
<li style={{ fontSize: 11, color: '#6b7280' }}>저장된 실행 없음</li>
|
||||
)}
|
||||
{(runs || []).map((r) => (
|
||||
<li key={r.id} style={{
|
||||
padding: '6px 0', borderBottom: '1px solid #1f2937', cursor: 'pointer',
|
||||
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb',
|
||||
}}
|
||||
onClick={() => onSelect?.(r.id)}>
|
||||
{r.asof} · {r.mode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
const NODE_ICONS = {
|
||||
foreign_buy: { icon: '👤', label: '외국인' },
|
||||
volume_surge: { icon: '⚡', label: '거래량' },
|
||||
momentum: { icon: '🚀', label: '모멘텀' },
|
||||
high52w: { icon: '🆙', label: '52w고' },
|
||||
rs_rating: { icon: '💪', label: 'RS' },
|
||||
ma_alignment: { icon: '📈', label: '정배열' },
|
||||
vcp_lite: { icon: '🌀', label: 'VCP' },
|
||||
const NODE_META = {
|
||||
foreign_buy: {
|
||||
label: '외국인',
|
||||
description: '외국인 누적 순매수 강도 — 최근 N일(기본 5일) 외국인 순매수 합계를 시가총액으로 나눈 비율의 백분위',
|
||||
},
|
||||
volume_surge: {
|
||||
label: '거래량 급증',
|
||||
description: '최근 3일 평균 거래량 vs 직전 20일 평균의 log(비율) 백분위 — 매집/관심 급증 신호',
|
||||
},
|
||||
momentum: {
|
||||
label: '20일 모멘텀',
|
||||
description: '20일 누적 수익률 백분위 — 단기 상승 추세 강도',
|
||||
},
|
||||
high52w: {
|
||||
label: '52주 신고가 근접도',
|
||||
description: '현재가 / 52주 최고가 (룰 기반: 70% 미만 0점, 100% 도달 100점, 선형) — 미너비니 SEPA 핵심',
|
||||
},
|
||||
rs_rating: {
|
||||
label: 'RS Rating',
|
||||
description: '시장(KOSPI) 대비 3·6·9·12개월 초과수익 가중합 (IBD 표준 2:1:1:1) 백분위 — 상대강도',
|
||||
},
|
||||
ma_alignment: {
|
||||
label: '이평선 정배열',
|
||||
description: '현재가>MA50, MA50>MA150, MA150>MA200, 현재가>MA200, 52주 저점+25% 이상 — 5조건 만족도 × 20점',
|
||||
},
|
||||
vcp_lite: {
|
||||
label: 'VCP-lite (변동성 수축)',
|
||||
description: '단기(40일) vs 장기(252일) 일중 변동성 비율 백분위 — 변동성 수축 = 돌파 직전 패턴',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ScoreChips({ scores }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{Object.entries(scores || {}).map(([name, s]) => {
|
||||
const meta = NODE_ICONS[name];
|
||||
const meta = NODE_META[name];
|
||||
if (!meta) return null;
|
||||
const active = s >= 70;
|
||||
const score = Math.round(s);
|
||||
return (
|
||||
<span key={name}
|
||||
title={`${meta.label}: ${s.toFixed?.(0) ?? s}`}
|
||||
style={{
|
||||
padding: '2px 6px', borderRadius: 4, fontSize: 11,
|
||||
background: active ? '#fbbf24' : '#1f2937',
|
||||
color: active ? '#0b0f17' : '#9ca3af',
|
||||
}}>
|
||||
{meta.icon}{Math.round(s)}
|
||||
<span
|
||||
key={name}
|
||||
title={`${meta.label} ${score}점\n\n${meta.description}\n\n(70점 이상이면 강조 표시)`}
|
||||
style={{
|
||||
padding: '3px 8px', borderRadius: 4, fontSize: 11,
|
||||
background: active ? '#fbbf24' : '#1f2937',
|
||||
color: active ? '#0b0f17' : '#9ca3af',
|
||||
cursor: 'help',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{meta.label} {score}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
196
src/pages/stock/screener/components/canvas/Canvas.css
Normal file
196
src/pages/stock/screener/components/canvas/Canvas.css
Normal file
@@ -0,0 +1,196 @@
|
||||
/* ─────────── ModeToggle 헤더 컨트롤 ─────────── */
|
||||
.screener-mode-toggle {
|
||||
display: inline-flex;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.screener-mode-toggle button {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.screener-mode-toggle button.active {
|
||||
background: #fbbf24;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.screener-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ─────────── CanvasLayout 그리드 ─────────── */
|
||||
.screener-canvas-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.screener-canvas-area {
|
||||
height: 65vh;
|
||||
min-height: 480px;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #0b1220;
|
||||
}
|
||||
.screener-canvas-results {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 16px;
|
||||
}
|
||||
.screener-canvas-results-main { display: flex; flex-direction: column; gap: 12px; }
|
||||
.screener-canvas-results-side { min-width: 0; }
|
||||
|
||||
/* ─────────── React Flow 내부 ─────────── */
|
||||
.screener-canvas-wrap { width: 100%; height: 100%; }
|
||||
|
||||
/* ─────────── 노드 카드 공통 ─────────── */
|
||||
.canvas-node {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
color: #e5e7eb;
|
||||
font-size: 12px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.canvas-node-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.canvas-node-icon { font-size: 14px; }
|
||||
.canvas-node-info {
|
||||
margin-left: auto;
|
||||
color: #9ca3af;
|
||||
cursor: help;
|
||||
}
|
||||
.canvas-node-subtitle,
|
||||
.canvas-node-summary {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ─────────── 고정 노드 (회색) ─────────── */
|
||||
.canvas-node--fixed { width: 200px; }
|
||||
.canvas-node--data { border-left: 3px solid #4b5563; }
|
||||
.canvas-node--combine { border-left: 3px solid #6b7280; }
|
||||
.canvas-node--result { border-left: 3px solid #6b7280; }
|
||||
|
||||
/* ─────────── 게이트 노드 (노랑) ─────────── */
|
||||
.canvas-node--gate {
|
||||
width: 220px;
|
||||
border-left: 4px solid #facc15;
|
||||
}
|
||||
|
||||
/* ─────────── 점수 노드 (accent) ─────────── */
|
||||
.canvas-node--score {
|
||||
width: 240px;
|
||||
border-left: 4px solid var(--canvas-accent, #3b82f6);
|
||||
}
|
||||
.canvas-node--score.is-inactive {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
.canvas-node-weight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.canvas-node-weight input[type=range] { flex: 1; }
|
||||
.canvas-node-weight-value {
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
color: var(--canvas-accent, #3b82f6);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.canvas-node-active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
color: #d1d5db;
|
||||
}
|
||||
.canvas-node-expand {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 1px dashed #374151;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.canvas-node-params {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.canvas-param-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #d1d5db;
|
||||
font-size: 11px;
|
||||
}
|
||||
.canvas-param-field input[type=number] {
|
||||
width: 70px;
|
||||
background: #0b1220;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* ─────────── floating toolbar ─────────── */
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(17, 24, 39, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.canvas-toolbar-btn {
|
||||
padding: 6px 12px;
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.canvas-toolbar-btn:hover:not(:disabled) {
|
||||
background: #374151;
|
||||
}
|
||||
.canvas-toolbar-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.canvas-toolbar-btn--primary {
|
||||
background: #fbbf24;
|
||||
color: #111827;
|
||||
border-color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
.canvas-toolbar-btn--primary:hover:not(:disabled) { background: #f59e0b; }
|
||||
|
||||
/* ─────────── 모바일 (캔버스는 숨겨지므로 ModeToggle만 영향) ─────────── */
|
||||
@media (max-width: 768px) {
|
||||
.screener-canvas-results { grid-template-columns: 1fr; }
|
||||
}
|
||||
56
src/pages/stock/screener/components/canvas/CanvasLayout.jsx
Normal file
56
src/pages/stock/screener/components/canvas/CanvasLayout.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import ScreenerCanvas from './ScreenerCanvas';
|
||||
import ResultTable from '../ResultTable';
|
||||
import TelegramPreview from '../TelegramPreview';
|
||||
import RunHistoryList from '../RunHistoryList';
|
||||
|
||||
export default function CanvasLayout({
|
||||
meta, settings, setLocal, save, dirty,
|
||||
result, running, previewHistory, runPreview, runSave, selectPreview,
|
||||
runs, runs_loading, selectRun, selectedRun,
|
||||
compareId, setCompareId,
|
||||
}) {
|
||||
const compareItem = previewHistory.find((p) => p.id === compareId);
|
||||
const compareResult = compareItem?.result ?? null;
|
||||
const activeResult = selectedRun || result;
|
||||
|
||||
return (
|
||||
<div className="screener-canvas-layout">
|
||||
<section className="screener-canvas-area">
|
||||
<ScreenerCanvas
|
||||
meta={meta}
|
||||
settings={settings}
|
||||
setLocal={setLocal}
|
||||
result={activeResult}
|
||||
running={running}
|
||||
dirty={dirty}
|
||||
onRunPreview={() => runPreview(settings)}
|
||||
onRunSave={() => runSave(settings)}
|
||||
onPersistSettings={save}
|
||||
/>
|
||||
</section>
|
||||
<section className="screener-canvas-results">
|
||||
<div className="screener-canvas-results-main">
|
||||
<ResultTable
|
||||
result={activeResult}
|
||||
compareWith={compareResult}
|
||||
compareLabel={compareItem ? new Date(compareItem.timestamp).toLocaleTimeString() : null}
|
||||
/>
|
||||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||
</div>
|
||||
<aside className="screener-canvas-results-side">
|
||||
<RunHistoryList
|
||||
runs={runs}
|
||||
loading={runs_loading}
|
||||
onSelect={selectRun}
|
||||
selectedId={selectedRun?.meta?.id}
|
||||
previewHistory={previewHistory}
|
||||
onSelectPreview={selectPreview}
|
||||
onSetCompare={setCompareId}
|
||||
compareId={compareId}
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/pages/stock/screener/components/canvas/CanvasToolbar.jsx
Normal file
61
src/pages/stock/screener/components/canvas/CanvasToolbar.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Panel, useReactFlow } from '@xyflow/react';
|
||||
|
||||
export default function CanvasToolbar({
|
||||
onRunPreview,
|
||||
onRunSave,
|
||||
onPersistSettings,
|
||||
onResetLayout,
|
||||
dirty,
|
||||
running,
|
||||
}) {
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
return (
|
||||
<Panel position="top-left" className="canvas-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-toolbar-btn canvas-toolbar-btn--primary"
|
||||
disabled={running}
|
||||
onClick={onRunPreview}
|
||||
title="현재 가중치로 미리보기 실행"
|
||||
>
|
||||
{running ? '실행 중…' : '▶ 실행'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-toolbar-btn"
|
||||
disabled={running}
|
||||
onClick={onRunSave}
|
||||
title="실행 결과를 DB에 저장"
|
||||
>
|
||||
💾 저장 실행
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-toolbar-btn"
|
||||
disabled={!dirty}
|
||||
onClick={onPersistSettings}
|
||||
title="현재 설정을 영구 저장"
|
||||
>
|
||||
📌 설정 저장{dirty ? ' *' : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-toolbar-btn"
|
||||
onClick={onResetLayout}
|
||||
title="노드 위치를 초기 좌표로 복귀"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-toolbar-btn"
|
||||
onClick={() => fitView({ padding: 0.2, duration: 300 })}
|
||||
title="화면에 맞춤"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
196
src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
Normal file
196
src/pages/stock/screener/components/canvas/ScreenerCanvas.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
ReactFlow, Background, Controls, ReactFlowProvider,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import {
|
||||
NODE_IDS, NODE_KIND_MAP, SCORE_NODE_NAME_MAP,
|
||||
EDGES, SCORE_NODE_LABEL, INITIAL_NODE_POSITIONS,
|
||||
} from './constants/canvasLayout';
|
||||
import { useCanvasLayout } from '../../hooks/useCanvasLayout';
|
||||
|
||||
import ScoreNodeCard from './nodes/ScoreNodeCard';
|
||||
import GateNodeCard from './nodes/GateNodeCard';
|
||||
import FixedNodeCard from './nodes/FixedNodeCard';
|
||||
import CanvasToolbar from './CanvasToolbar';
|
||||
|
||||
const nodeTypes = {
|
||||
score: ScoreNodeCard,
|
||||
gate: GateNodeCard,
|
||||
fixed: FixedNodeCard,
|
||||
};
|
||||
|
||||
function buildEdges(weights) {
|
||||
return EDGES.map((e) => {
|
||||
const targetKind = NODE_KIND_MAP[e.target];
|
||||
const sourceKind = NODE_KIND_MAP[e.source];
|
||||
// gate → 점수: 해당 점수 노드 weight 가 활성인지에 따라 stroke
|
||||
let active = true;
|
||||
if (sourceKind === 'gate' && targetKind === 'score') {
|
||||
const nodeName = SCORE_NODE_NAME_MAP[e.target];
|
||||
active = (weights?.[nodeName] ?? 0) > 0;
|
||||
} else if (sourceKind === 'score' && targetKind === 'combine') {
|
||||
const nodeName = SCORE_NODE_NAME_MAP[e.source];
|
||||
active = (weights?.[nodeName] ?? 0) > 0;
|
||||
}
|
||||
return {
|
||||
...e,
|
||||
animated: active,
|
||||
style: {
|
||||
stroke: active ? '#fbbf24' : '#374151',
|
||||
strokeWidth: active ? 1.5 : 1,
|
||||
strokeDasharray: active ? undefined : '4 4',
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function ScreenerCanvasInner({
|
||||
meta, settings, setLocal, result, running, dirty,
|
||||
onRunPreview, onRunSave, onPersistSettings,
|
||||
}) {
|
||||
const { positions, updateNodePosition, reset } = useCanvasLayout(INITIAL_NODE_POSITIONS);
|
||||
|
||||
const onWeightChange = useCallback((nodeId, weight) => {
|
||||
const name = SCORE_NODE_NAME_MAP[nodeId];
|
||||
if (!name) return;
|
||||
setLocal({ ...settings, weights: { ...settings.weights, [name]: weight } });
|
||||
}, [settings, setLocal]);
|
||||
|
||||
const onParamsChange = useCallback((nodeId, params) => {
|
||||
const name = SCORE_NODE_NAME_MAP[nodeId];
|
||||
if (!name) return;
|
||||
setLocal({ ...settings, node_params: { ...settings.node_params, [name]: params } });
|
||||
}, [settings, setLocal]);
|
||||
|
||||
const onGateParamsChange = useCallback((params) => {
|
||||
setLocal({ ...settings, gate_params: params });
|
||||
}, [settings, setLocal]);
|
||||
|
||||
const scoreMetaByName = useMemo(() => {
|
||||
const map = {};
|
||||
for (const m of meta?.score_nodes ?? []) map[m.name] = m;
|
||||
return map;
|
||||
}, [meta]);
|
||||
|
||||
const gateMeta = meta?.gate_nodes?.[0];
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
const arr = [];
|
||||
arr.push({
|
||||
id: NODE_IDS.DATA,
|
||||
type: 'fixed',
|
||||
position: positions[NODE_IDS.DATA],
|
||||
data: { icon: '📥', title: 'KRX 데이터', subtitle: '~2,800종목 · FDR', kind: 'data' },
|
||||
draggable: true,
|
||||
});
|
||||
arr.push({
|
||||
id: NODE_IDS.GATE,
|
||||
type: 'gate',
|
||||
position: positions[NODE_IDS.GATE],
|
||||
data: {
|
||||
meta: gateMeta,
|
||||
params: settings.gate_params,
|
||||
description: gateMeta?.label || '위생 게이트',
|
||||
onChange: onGateParamsChange,
|
||||
},
|
||||
draggable: true,
|
||||
});
|
||||
for (const [nodeId, backendName] of Object.entries(SCORE_NODE_NAME_MAP)) {
|
||||
const m = scoreMetaByName[backendName];
|
||||
const label = SCORE_NODE_LABEL[nodeId] || { icon: '📈', title: backendName };
|
||||
arr.push({
|
||||
id: nodeId,
|
||||
type: 'score',
|
||||
position: positions[nodeId],
|
||||
data: {
|
||||
meta: m ? { ...m, label: label.title } : { name: backendName, label: label.title },
|
||||
weight: settings.weights?.[backendName] ?? 0,
|
||||
params: settings.node_params?.[backendName] ?? {},
|
||||
summary: m?.summary || '',
|
||||
description: m?.description || m?.label || '',
|
||||
accent: m?.color || '#3b82f6',
|
||||
icon: label.icon,
|
||||
onWeightChange: (w) => onWeightChange(nodeId, w),
|
||||
onParamsChange: (p) => onParamsChange(nodeId, p),
|
||||
},
|
||||
draggable: true,
|
||||
});
|
||||
}
|
||||
const tp = settings.top_n;
|
||||
const rr = settings.rr_ratio;
|
||||
const am = settings.atr_stop_mult;
|
||||
arr.push({
|
||||
id: NODE_IDS.COMBINE,
|
||||
type: 'fixed',
|
||||
position: positions[NODE_IDS.COMBINE],
|
||||
data: {
|
||||
icon: '⚙️',
|
||||
title: '가중합 + TopN + ATR',
|
||||
subtitle: `Top ${tp} · RR ${rr} · ATR×${am}`,
|
||||
kind: 'combine',
|
||||
},
|
||||
draggable: true,
|
||||
});
|
||||
const survivors = result?.survivors_count;
|
||||
const asof = result?.asof;
|
||||
arr.push({
|
||||
id: NODE_IDS.RESULT,
|
||||
type: 'fixed',
|
||||
position: positions[NODE_IDS.RESULT],
|
||||
data: {
|
||||
icon: '📊',
|
||||
title: '결과',
|
||||
subtitle: asof ? `${asof} · ${survivors ?? '-'}종목 통과` : '아직 실행 안 됨',
|
||||
kind: 'result',
|
||||
},
|
||||
draggable: true,
|
||||
});
|
||||
return arr;
|
||||
}, [positions, settings, meta, scoreMetaByName, gateMeta,
|
||||
onWeightChange, onParamsChange, onGateParamsChange, result]);
|
||||
|
||||
const edges = useMemo(() => buildEdges(settings.weights), [settings.weights]);
|
||||
|
||||
const handleNodeDragStop = useCallback((_evt, node) => {
|
||||
updateNodePosition(node.id, node.position);
|
||||
}, [updateNodePosition]);
|
||||
|
||||
return (
|
||||
<div className="screener-canvas-wrap">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesConnectable={false}
|
||||
edgesUpdatable={false}
|
||||
edgesFocusable={false}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.85 }}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={20} size={1} color="#1f2937" />
|
||||
<Controls showInteractive={false} />
|
||||
<CanvasToolbar
|
||||
onRunPreview={onRunPreview}
|
||||
onRunSave={onRunSave}
|
||||
onPersistSettings={onPersistSettings}
|
||||
onResetLayout={reset}
|
||||
dirty={dirty}
|
||||
running={running}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScreenerCanvas(props) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ScreenerCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export const NODE_IDS = {
|
||||
DATA: 'data',
|
||||
GATE: 'gate-hygiene',
|
||||
FOREIGN: 'score-foreign-buy',
|
||||
VOLUME: 'score-volume-surge',
|
||||
MOMENTUM: 'score-momentum',
|
||||
HIGH52W: 'score-high52w',
|
||||
RS: 'score-rs-rating',
|
||||
MA: 'score-ma-alignment',
|
||||
VCP: 'score-vcp-lite',
|
||||
AI_NEWS: 'score-ai-news',
|
||||
COMBINE: 'combine',
|
||||
RESULT: 'result',
|
||||
};
|
||||
|
||||
export const NODE_KIND_MAP = {
|
||||
[NODE_IDS.DATA]: 'data',
|
||||
[NODE_IDS.GATE]: 'gate',
|
||||
[NODE_IDS.FOREIGN]: 'score',
|
||||
[NODE_IDS.VOLUME]: 'score',
|
||||
[NODE_IDS.MOMENTUM]: 'score',
|
||||
[NODE_IDS.HIGH52W]: 'score',
|
||||
[NODE_IDS.RS]: 'score',
|
||||
[NODE_IDS.MA]: 'score',
|
||||
[NODE_IDS.VCP]: 'score',
|
||||
[NODE_IDS.AI_NEWS]: 'score',
|
||||
[NODE_IDS.COMBINE]: 'combine',
|
||||
[NODE_IDS.RESULT]: 'result',
|
||||
};
|
||||
|
||||
// 캔버스 노드 ID → 백엔드 score node name (registry 키)
|
||||
export const SCORE_NODE_NAME_MAP = {
|
||||
[NODE_IDS.FOREIGN]: 'foreign_buy',
|
||||
[NODE_IDS.VOLUME]: 'volume_surge',
|
||||
[NODE_IDS.MOMENTUM]: 'momentum',
|
||||
[NODE_IDS.HIGH52W]: 'high52w',
|
||||
[NODE_IDS.RS]: 'rs_rating',
|
||||
[NODE_IDS.MA]: 'ma_alignment',
|
||||
[NODE_IDS.VCP]: 'vcp_lite',
|
||||
[NODE_IDS.AI_NEWS]: 'ai_news',
|
||||
};
|
||||
|
||||
// 4단 layout: DATA → GATE → (점수 7개 세로) → COMBINE → RESULT
|
||||
export const INITIAL_NODE_POSITIONS = {
|
||||
[NODE_IDS.DATA]: { x: 40, y: 280 },
|
||||
[NODE_IDS.GATE]: { x: 240, y: 280 },
|
||||
[NODE_IDS.FOREIGN]: { x: 480, y: 0 },
|
||||
[NODE_IDS.VOLUME]: { x: 480, y: 90 },
|
||||
[NODE_IDS.MOMENTUM]: { x: 480, y: 180 },
|
||||
[NODE_IDS.HIGH52W]: { x: 480, y: 270 },
|
||||
[NODE_IDS.RS]: { x: 480, y: 360 },
|
||||
[NODE_IDS.MA]: { x: 480, y: 450 },
|
||||
[NODE_IDS.VCP]: { x: 480, y: 540 },
|
||||
[NODE_IDS.AI_NEWS]: { x: 480, y: 630 },
|
||||
[NODE_IDS.COMBINE]: { x: 800, y: 280 },
|
||||
[NODE_IDS.RESULT]: { x: 1080, y: 280 },
|
||||
};
|
||||
|
||||
const SCORE_KEYS = ['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP','AI_NEWS'];
|
||||
|
||||
export const EDGES = [
|
||||
{ id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
|
||||
...SCORE_KEYS.map((k) => ({
|
||||
id: `e-gate-${k.toLowerCase()}`,
|
||||
source: NODE_IDS.GATE,
|
||||
target: NODE_IDS[k],
|
||||
})),
|
||||
...SCORE_KEYS.map((k) => ({
|
||||
id: `e-${k.toLowerCase()}-combine`,
|
||||
source: NODE_IDS[k],
|
||||
target: NODE_IDS.COMBINE,
|
||||
})),
|
||||
{ id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT },
|
||||
];
|
||||
|
||||
export const SCORE_NODE_LABEL = {
|
||||
[NODE_IDS.FOREIGN]: { icon: '🌏', title: '외국인 매수' },
|
||||
[NODE_IDS.VOLUME]: { icon: '📊', title: '거래량 급증' },
|
||||
[NODE_IDS.MOMENTUM]: { icon: '🚀', title: '모멘텀' },
|
||||
[NODE_IDS.HIGH52W]: { icon: '🔝', title: '52주 고가' },
|
||||
[NODE_IDS.RS]: { icon: '💪', title: 'RS Rating' },
|
||||
[NODE_IDS.MA]: { icon: '📈', title: '이평선 정렬' },
|
||||
[NODE_IDS.VCP]: { icon: '🌀', title: 'VCP-lite' },
|
||||
[NODE_IDS.AI_NEWS]: { icon: '🤖', title: 'AI 뉴스' },
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
NODE_IDS, INITIAL_NODE_POSITIONS, EDGES,
|
||||
NODE_KIND_MAP, SCORE_NODE_NAME_MAP,
|
||||
} from './canvasLayout';
|
||||
|
||||
describe('canvasLayout', () => {
|
||||
it('NODE_IDS — 12개 키, 모두 unique', () => {
|
||||
const ids = Object.values(NODE_IDS);
|
||||
expect(ids).toHaveLength(12);
|
||||
expect(new Set(ids).size).toBe(12);
|
||||
});
|
||||
|
||||
it('INITIAL_NODE_POSITIONS — 모든 NODE_IDS에 좌표 존재', () => {
|
||||
for (const id of Object.values(NODE_IDS)) {
|
||||
expect(INITIAL_NODE_POSITIONS[id]).toMatchObject({
|
||||
x: expect.any(Number),
|
||||
y: expect.any(Number),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('EDGES — 18개, source/target이 모두 NODE_IDS 안에 존재', () => {
|
||||
expect(EDGES).toHaveLength(18);
|
||||
const validIds = new Set(Object.values(NODE_IDS));
|
||||
for (const e of EDGES) {
|
||||
expect(validIds.has(e.source)).toBe(true);
|
||||
expect(validIds.has(e.target)).toBe(true);
|
||||
expect(e.id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('EDGES — 8개 점수 노드는 모두 gate 입력 + combine 출력을 가짐', () => {
|
||||
const SCORE_IDS = [
|
||||
NODE_IDS.FOREIGN, NODE_IDS.VOLUME, NODE_IDS.MOMENTUM,
|
||||
NODE_IDS.HIGH52W, NODE_IDS.RS, NODE_IDS.MA, NODE_IDS.VCP,
|
||||
NODE_IDS.AI_NEWS,
|
||||
];
|
||||
for (const sid of SCORE_IDS) {
|
||||
const hasGateInput = EDGES.some(
|
||||
(e) => e.source === NODE_IDS.GATE && e.target === sid
|
||||
);
|
||||
const hasCombineOutput = EDGES.some(
|
||||
(e) => e.source === sid && e.target === NODE_IDS.COMBINE
|
||||
);
|
||||
expect(hasGateInput).toBe(true);
|
||||
expect(hasCombineOutput).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('NODE_KIND_MAP — 각 노드의 kind ∈ {data,gate,score,combine,result}', () => {
|
||||
const valid = new Set(['data','gate','score','combine','result']);
|
||||
for (const id of Object.values(NODE_IDS)) {
|
||||
expect(valid.has(NODE_KIND_MAP[id])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('SCORE_NODE_NAME_MAP — 8개 점수 노드 ID → backend node name', () => {
|
||||
expect(Object.keys(SCORE_NODE_NAME_MAP)).toHaveLength(8);
|
||||
expect(SCORE_NODE_NAME_MAP[NODE_IDS.FOREIGN]).toBe('foreign_buy');
|
||||
expect(SCORE_NODE_NAME_MAP[NODE_IDS.VOLUME]).toBe('volume_surge');
|
||||
expect(SCORE_NODE_NAME_MAP[NODE_IDS.AI_NEWS]).toBe('ai_news');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
function FixedNodeCard({ data }) {
|
||||
const { icon, title, subtitle, kind } = data;
|
||||
const hasInput = kind !== 'data';
|
||||
const hasOutput = kind !== 'result';
|
||||
|
||||
return (
|
||||
<div className={`canvas-node canvas-node--fixed canvas-node--${kind}`}>
|
||||
{hasInput && <Handle type="target" position={Position.Left} isConnectable={false} />}
|
||||
<div className="canvas-node-title">
|
||||
<span className="canvas-node-icon">{icon}</span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{subtitle && <div className="canvas-node-subtitle">{subtitle}</div>}
|
||||
{hasOutput && <Handle type="source" position={Position.Right} isConnectable={false} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(FixedNodeCard);
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
function ParamField({ name, schema, value, onChange }) {
|
||||
if (schema?.type === 'boolean') {
|
||||
return (
|
||||
<label className="canvas-param-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(name, e.target.checked)}
|
||||
/>
|
||||
<span>{schema.label || name}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label className="canvas-param-field">
|
||||
<span>{schema?.label || name}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? schema?.default ?? 0}
|
||||
step={schema?.step ?? 1}
|
||||
onChange={(e) => onChange(name, Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function GateNodeCard({ data }) {
|
||||
const { meta, params, onChange, description } = data;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const update = (key, v) => onChange({ ...params, [key]: v });
|
||||
|
||||
return (
|
||||
<div className="canvas-node canvas-node--gate">
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} />
|
||||
<div className="canvas-node-title">
|
||||
<span className="canvas-node-icon">🛡️</span>
|
||||
<span>{meta?.label || '위생 게이트'}</span>
|
||||
{description && (
|
||||
<span className="canvas-node-info" title={description}>ⓘ</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="canvas-node-summary">통과해야 점수 단계 진입</div>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-node-expand"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? '▴ 파라미터' : '▾ 파라미터'}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="canvas-node-params">
|
||||
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
|
||||
<ParamField
|
||||
key={key}
|
||||
name={key}
|
||||
schema={schema}
|
||||
value={params?.[key]}
|
||||
onChange={update}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(GateNodeCard);
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
const DEFAULT_WEIGHT = 0.5;
|
||||
|
||||
function ParamField({ name, schema, value, onChange }) {
|
||||
return (
|
||||
<label className="canvas-param-field">
|
||||
<span>{schema?.label || name}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? schema?.default ?? 0}
|
||||
step={schema?.step ?? 1}
|
||||
onChange={(e) => onChange(name, Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreNodeCard({ data }) {
|
||||
const {
|
||||
meta, weight, params, summary, description, accent, icon,
|
||||
onWeightChange, onParamsChange,
|
||||
} = data;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const active = weight > 0;
|
||||
|
||||
const toggleActive = () => {
|
||||
if (active) onWeightChange(0);
|
||||
else onWeightChange(DEFAULT_WEIGHT);
|
||||
};
|
||||
|
||||
const updateParam = (key, v) =>
|
||||
onParamsChange({ ...params, [key]: v });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`canvas-node canvas-node--score ${active ? '' : 'is-inactive'}`}
|
||||
style={{ '--canvas-accent': accent || '#3b82f6' }}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} />
|
||||
<div className="canvas-node-title">
|
||||
<span className="canvas-node-icon">{icon}</span>
|
||||
<span>{meta?.label || meta?.name}</span>
|
||||
{description && (
|
||||
<span className="canvas-node-info" title={description}>ⓘ</span>
|
||||
)}
|
||||
</div>
|
||||
{summary && <div className="canvas-node-summary">{summary}</div>}
|
||||
<div className="canvas-node-weight">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={weight}
|
||||
onChange={(e) => onWeightChange(Number(e.target.value))}
|
||||
aria-label="가중치"
|
||||
/>
|
||||
<span className="canvas-node-weight-value">{weight.toFixed(2)}</span>
|
||||
</div>
|
||||
<label className="canvas-node-active">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={toggleActive}
|
||||
/>
|
||||
<span>활성</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-node-expand"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? '▴ 파라미터' : '▾ 파라미터'}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="canvas-node-params">
|
||||
{Object.entries(meta?.param_schema || {}).map(([key, schema]) => (
|
||||
<ParamField
|
||||
key={key}
|
||||
name={key}
|
||||
schema={schema}
|
||||
value={params?.[key]}
|
||||
onChange={updateParam}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ScoreNodeCard);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import ScoreNodeCard from './ScoreNodeCard';
|
||||
|
||||
const baseData = {
|
||||
meta: {
|
||||
name: 'volume_surge',
|
||||
label: '거래량 급증',
|
||||
param_schema: {
|
||||
lookback_days: { type: 'integer', default: 20, label: 'lookback' },
|
||||
multiplier: { type: 'number', default: 2.0, step: 0.1, label: 'mult' },
|
||||
},
|
||||
},
|
||||
weight: 0.5,
|
||||
params: { lookback_days: 20, multiplier: 2.0 },
|
||||
summary: '20일 평균 대비 2배 이상',
|
||||
description: '거래량이 평균 대비 급증한 종목을 가산',
|
||||
accent: '#3b82f6',
|
||||
onWeightChange: vi.fn(),
|
||||
onParamsChange: vi.fn(),
|
||||
};
|
||||
|
||||
function renderInFlow(data) {
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<ScoreNodeCard data={data} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ScoreNodeCard', () => {
|
||||
it('타이틀과 한 줄 요약을 표시한다', () => {
|
||||
renderInFlow(baseData);
|
||||
expect(screen.getByText('거래량 급증')).toBeInTheDocument();
|
||||
expect(screen.getByText('20일 평균 대비 2배 이상')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('가중치 슬라이더 변경 시 onWeightChange 호출', () => {
|
||||
const onWeightChange = vi.fn();
|
||||
renderInFlow({ ...baseData, onWeightChange });
|
||||
const slider = screen.getByRole('slider');
|
||||
fireEvent.change(slider, { target: { value: '0.8' } });
|
||||
expect(onWeightChange).toHaveBeenCalledWith(0.8);
|
||||
});
|
||||
|
||||
it('활성 체크박스 uncheck 시 onWeightChange(0)', () => {
|
||||
const onWeightChange = vi.fn();
|
||||
renderInFlow({ ...baseData, weight: 0.5, onWeightChange });
|
||||
const checkbox = screen.getByRole('checkbox', { name: /활성/ });
|
||||
expect(checkbox).toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
expect(onWeightChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('weight=0 상태에서 활성 체크 시 기본값 0.5로 복원', () => {
|
||||
const onWeightChange = vi.fn();
|
||||
renderInFlow({ ...baseData, weight: 0, onWeightChange });
|
||||
const checkbox = screen.getByRole('checkbox', { name: /활성/ });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
expect(onWeightChange).toHaveBeenCalledWith(0.5);
|
||||
});
|
||||
|
||||
it('파라미터 펼치기 토글', () => {
|
||||
renderInFlow(baseData);
|
||||
expect(screen.queryByLabelText('lookback')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /파라미터/ }));
|
||||
expect(screen.getByLabelText('lookback')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
45
src/pages/stock/screener/hooks/useCanvasLayout.js
Normal file
45
src/pages/stock/screener/hooks/useCanvasLayout.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'screener-canvas-layout-v1';
|
||||
|
||||
function readPositions(initial) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return initial;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return initial;
|
||||
// 누락 ID 보충
|
||||
return { ...initial, ...filterValidEntries(parsed) };
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
}
|
||||
|
||||
function filterValidEntries(obj) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v && typeof v.x === 'number' && typeof v.y === 'number') {
|
||||
out[k] = { x: v.x, y: v.y };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function useCanvasLayout(initialPositions) {
|
||||
const [positions, setPositions] = useState(() => readPositions(initialPositions));
|
||||
|
||||
const updateNodePosition = useCallback((nodeId, pos) => {
|
||||
setPositions((prev) => {
|
||||
const next = { ...prev, [nodeId]: { x: pos.x, y: pos.y } };
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* ignore quota/security errors */ }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPositions(initialPositions);
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore security errors */ }
|
||||
}, [initialPositions]);
|
||||
|
||||
return { positions, updateNodePosition, reset };
|
||||
}
|
||||
49
src/pages/stock/screener/hooks/useCanvasLayout.test.js
Normal file
49
src/pages/stock/screener/hooks/useCanvasLayout.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCanvasLayout } from './useCanvasLayout';
|
||||
|
||||
const INITIAL = {
|
||||
a: { x: 0, y: 0 },
|
||||
b: { x: 100, y: 100 },
|
||||
c: { x: 200, y: 200 },
|
||||
};
|
||||
|
||||
describe('useCanvasLayout', () => {
|
||||
it('초기 호출 시 INITIAL 반환', () => {
|
||||
const { result } = renderHook(() => useCanvasLayout(INITIAL));
|
||||
expect(result.current.positions).toEqual(INITIAL);
|
||||
});
|
||||
|
||||
it('updateNodePosition 호출 시 state + localStorage 모두 갱신', () => {
|
||||
const { result } = renderHook(() => useCanvasLayout(INITIAL));
|
||||
act(() => result.current.updateNodePosition('a', { x: 50, y: 50 }));
|
||||
expect(result.current.positions.a).toEqual({ x: 50, y: 50 });
|
||||
const stored = JSON.parse(localStorage.getItem('screener-canvas-layout-v1'));
|
||||
expect(stored.a).toEqual({ x: 50, y: 50 });
|
||||
});
|
||||
|
||||
it('reset 호출 시 INITIAL 복원 + localStorage 삭제', () => {
|
||||
const { result } = renderHook(() => useCanvasLayout(INITIAL));
|
||||
act(() => result.current.updateNodePosition('a', { x: 50, y: 50 }));
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.positions).toEqual(INITIAL);
|
||||
expect(localStorage.getItem('screener-canvas-layout-v1')).toBeNull();
|
||||
});
|
||||
|
||||
it('손상된 localStorage 는 INITIAL 로 fallback', () => {
|
||||
localStorage.setItem('screener-canvas-layout-v1', 'NOT_JSON');
|
||||
const { result } = renderHook(() => useCanvasLayout(INITIAL));
|
||||
expect(result.current.positions).toEqual(INITIAL);
|
||||
});
|
||||
|
||||
it('localStorage 에 일부 ID 만 있으면 누락 ID 는 INITIAL 보충', () => {
|
||||
localStorage.setItem(
|
||||
'screener-canvas-layout-v1',
|
||||
JSON.stringify({ a: { x: 999, y: 999 } })
|
||||
);
|
||||
const { result } = renderHook(() => useCanvasLayout(INITIAL));
|
||||
expect(result.current.positions.a).toEqual({ x: 999, y: 999 });
|
||||
expect(result.current.positions.b).toEqual({ x: 100, y: 100 });
|
||||
expect(result.current.positions.c).toEqual({ x: 200, y: 200 });
|
||||
});
|
||||
});
|
||||
25
src/pages/stock/screener/hooks/useScreenerMode.js
Normal file
25
src/pages/stock/screener/hooks/useScreenerMode.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'screener-mode-v1';
|
||||
const VALID_MODES = new Set(['form', 'canvas']);
|
||||
|
||||
function readMode() {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return VALID_MODES.has(v) ? v : 'form';
|
||||
} catch {
|
||||
return 'form';
|
||||
}
|
||||
}
|
||||
|
||||
export function useScreenerMode() {
|
||||
const [mode, setModeState] = useState(readMode);
|
||||
|
||||
const setMode = (m) => {
|
||||
if (!VALID_MODES.has(m)) return;
|
||||
setModeState(m);
|
||||
try { localStorage.setItem(STORAGE_KEY, m); } catch { /* ignore quota/security errors */ }
|
||||
};
|
||||
|
||||
return { mode, setMode };
|
||||
}
|
||||
29
src/pages/stock/screener/hooks/useScreenerMode.test.js
Normal file
29
src/pages/stock/screener/hooks/useScreenerMode.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useScreenerMode } from './useScreenerMode';
|
||||
|
||||
describe('useScreenerMode', () => {
|
||||
it('초기값은 "form"', () => {
|
||||
const { result } = renderHook(() => useScreenerMode());
|
||||
expect(result.current.mode).toBe('form');
|
||||
});
|
||||
|
||||
it('localStorage 에 저장된 값 복원', () => {
|
||||
localStorage.setItem('screener-mode-v1', 'canvas');
|
||||
const { result } = renderHook(() => useScreenerMode());
|
||||
expect(result.current.mode).toBe('canvas');
|
||||
});
|
||||
|
||||
it('손상된 localStorage 는 "form" 으로 fallback', () => {
|
||||
localStorage.setItem('screener-mode-v1', 'INVALID_MODE');
|
||||
const { result } = renderHook(() => useScreenerMode());
|
||||
expect(result.current.mode).toBe('form');
|
||||
});
|
||||
|
||||
it('setMode 호출 시 state 와 localStorage 모두 갱신', () => {
|
||||
const { result } = renderHook(() => useScreenerMode());
|
||||
act(() => result.current.setMode('canvas'));
|
||||
expect(result.current.mode).toBe('canvas');
|
||||
expect(localStorage.getItem('screener-mode-v1')).toBe('canvas');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { runScreener } from '../../../../api';
|
||||
|
||||
const MAX_PREVIEW_HISTORY = 10;
|
||||
|
||||
export function useScreenerRun() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
// 미리보기 결과를 세션 메모리에 누적 (새로고침 시 사라짐 — DB 부하 없음)
|
||||
const [previewHistory, setPreviewHistory] = useState([]);
|
||||
|
||||
async function call(mode, settings) {
|
||||
setRunning(true);
|
||||
@@ -17,15 +21,34 @@ export function useScreenerRun() {
|
||||
};
|
||||
const r = await runScreener(body);
|
||||
setResult(r);
|
||||
const stamp = new Date().toISOString();
|
||||
const item = {
|
||||
id: `${mode}-${stamp}`,
|
||||
mode,
|
||||
timestamp: stamp,
|
||||
asof: r?.asof,
|
||||
survivors_count: r?.survivors_count,
|
||||
top_ticker: r?.results?.[0]?.ticker,
|
||||
top_name: r?.results?.[0]?.name,
|
||||
top_score: r?.results?.[0]?.total_score,
|
||||
result: r,
|
||||
};
|
||||
setPreviewHistory((prev) => [item, ...prev].slice(0, MAX_PREVIEW_HISTORY));
|
||||
return r;
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreview(id) {
|
||||
const item = previewHistory.find((p) => p.id === id);
|
||||
if (item) setResult(item.result);
|
||||
}
|
||||
|
||||
return {
|
||||
result, running,
|
||||
result, running, previewHistory,
|
||||
runPreview: (s) => call('preview', s),
|
||||
runSave: (s) => call('manual_save', s),
|
||||
selectPreview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IconMusic,
|
||||
IconLab,
|
||||
IconTodo,
|
||||
IconBlogMarketing,
|
||||
IconInsta,
|
||||
IconPortfolio,
|
||||
} from './components/Icons';
|
||||
|
||||
@@ -26,7 +26,7 @@ const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||
const Todo = lazy(() => import('./pages/todo/Todo'));
|
||||
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
||||
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
|
||||
const InstaCards = lazy(() => import('./pages/insta/InstaCards'));
|
||||
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
|
||||
|
||||
export const navLinks = [
|
||||
@@ -103,13 +103,13 @@ export const navLinks = [
|
||||
accent: '#f43f5e',
|
||||
},
|
||||
{
|
||||
id: 'blog-lab',
|
||||
label: 'Blog Lab',
|
||||
path: '/blog-lab',
|
||||
subtitle: 'MONETIZE',
|
||||
description: 'AI 블로그 마케팅으로 수익을 만드는 연구소',
|
||||
icon: <IconBlogMarketing />,
|
||||
accent: '#10b981',
|
||||
id: 'insta',
|
||||
label: 'Insta',
|
||||
path: '/insta',
|
||||
subtitle: 'CARD FEED',
|
||||
description: '뉴스에서 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드',
|
||||
icon: <IconInsta />,
|
||||
accent: '#ec4899',
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
@@ -190,8 +190,8 @@ export const appRoutes = [
|
||||
element: <MusicStudio />,
|
||||
},
|
||||
{
|
||||
path: 'blog-lab',
|
||||
element: <BlogMarketing />,
|
||||
path: 'insta',
|
||||
element: <InstaCards />,
|
||||
},
|
||||
{
|
||||
path: 'todo',
|
||||
|
||||
35
src/test-setup.js
Normal file
35
src/test-setup.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// jsdom polyfills for react-flow
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
}
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
if (!window.DOMMatrixReadOnly) {
|
||||
window.DOMMatrixReadOnly = class {
|
||||
constructor() {}
|
||||
m22 = 1;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test-setup.js'],
|
||||
include: ['src/**/*.test.{js,jsx}'],
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 3007,
|
||||
|
||||
Reference in New Issue
Block a user