Compare commits
4 Commits
3bf7ce446f
...
f261a80d52
| Author | SHA1 | Date | |
|---|---|---|---|
| f261a80d52 | |||
| 42e9c8df27 | |||
| c84c6b5bac | |||
| 094366a162 |
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.
|
||||||
@@ -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.
|
||||||
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 자동화 — 일정 운영 안정화 후
|
||||||
@@ -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분 (남은 섹션)
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user