docs(signal-v2): Phase 1 stock WebAI API spec
3 endpoints + X-WebAI-Key auth + nginx rate limit + 15 tests. brainstorming 7 decisions: scope=B / auth=A(static key) / portfolio=B(pnl_pct) / news-sentiment=A(daily dump) / endpoint=1(/api/webai prefix) / rate=B(nginx) / test=B(pytest schema). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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 자동화 — 일정 운영 안정화 후
|
||||
Reference in New Issue
Block a user