# 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: ``` 응답 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: ``` 쿼리: - `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 자동화 — 일정 운영 안정화 후