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) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ from .db import (
|
|||||||
from .scraper import fetch_market_news, fetch_major_indices
|
from .scraper import fetch_market_news, fetch_major_indices
|
||||||
from .price_fetcher import get_current_prices, get_current_prices_detail
|
from .price_fetcher import get_current_prices, get_current_prices_detail
|
||||||
from .ai_summarizer import summarize_news, OllamaError
|
from .ai_summarizer import summarize_news, OllamaError
|
||||||
|
from .auth import verify_webai_key
|
||||||
|
|
||||||
app = FastAPI()
|
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)
|
@app.post("/api/portfolio", status_code=201)
|
||||||
def create_portfolio_item(req: PortfolioItemRequest):
|
def create_portfolio_item(req: PortfolioItemRequest):
|
||||||
"""포트폴리오 종목 추가"""
|
"""포트폴리오 종목 추가"""
|
||||||
|
|||||||
89
stock/app/test_webai_endpoints.py
Normal file
89
stock/app/test_webai_endpoints.py
Normal file
@@ -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"]
|
||||||
Reference in New Issue
Block a user