# 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 자동화 — 일정 운영 안정화 후
