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>
15 KiB
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_keyFastAPI dependency, envWEBAI_API_KEY - ④ nginx
/api/webai/*location +limit_reqrate 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 추가:
{
"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:
{
"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.41회 기록 (IP 만, 키는 로그하지 않음)
4.4 운영 .env 누락 시
env WEBAI_API_KEY 가 빈 문자열 또는 미정의 시:
- startup 시점에 stock logger 가
ERROR WEBAI_API_KEY not configured1회 출력 /api/webai/*호출은 모두 503{"detail": "webai auth not configured"}- 다른 endpoint (
/api/portfolio,/api/stock/*) 영향 없음
5. 인증 구현
stock/app/auth.py:
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 적용:
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 옆에 추가)
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
6.2 server {} 블록 내 신규 location (/api/stock/ location 위에 우선순위)
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_pcttest_portfolio_summary_has_total_pnl_pcttest_portfolio_pnl_pct_matches_profit_rate_divided_100test_portfolio_missing_key_returns_401
news-sentiment:
test_news_sentiment_returns_latest_date_when_no_paramtest_news_sentiment_filters_by_date_paramtest_news_sentiment_empty_table_returns_count_zerotest_news_sentiment_items_sorted_by_score_desc
공통:
test_401_response_has_no_payload_leaktest_503_when_webai_key_not_configuredtest_wrong_key_returns_401test_news_sentiment_unknown_date_returns_empty
7.3 Manual smoke (배포 후)
# 정상 통과
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 PASSstock/app/main.py의 2 신규 endpoint + 통합 테스트 12 PASSnginx/default.conf의limit_req_zone webai+/api/webai/location 추가docker-compose.yml의 stock envWEBAI_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 자동화 — 일정 운영 안정화 후