From 2abfa5cb237d12107ba80abe8aa0ff2683227311 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 15 May 2026 08:36:27 +0900 Subject: [PATCH] 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) --- stock/app/main.py | 21 ++++++++ stock/app/test_webai_endpoints.py | 89 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 stock/app/test_webai_endpoints.py diff --git a/stock/app/main.py b/stock/app/main.py index 4da8cf7..c444450 100644 --- a/stock/app/main.py +++ b/stock/app/main.py @@ -24,6 +24,7 @@ from .db import ( from .scraper import fetch_market_news, fetch_major_indices from .price_fetcher import get_current_prices, get_current_prices_detail from .ai_summarizer import summarize_news, OllamaError +from .auth import verify_webai_key app = FastAPI() @@ -384,6 +385,26 @@ def get_portfolio(): } +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()) + + @app.post("/api/portfolio", status_code=201) def create_portfolio_item(req: PortfolioItemRequest): """포트폴리오 종목 추가""" diff --git a/stock/app/test_webai_endpoints.py b/stock/app/test_webai_endpoints.py new file mode 100644 index 0000000..32bb40f --- /dev/null +++ b/stock/app/test_webai_endpoints.py @@ -0,0 +1,89 @@ +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"]