fix(stock,docs): portfolio total_buy 수량 곱산 + insta-trends spec 변경 이력 (F4 + F6)
[F4] /api/portfolio 응답의 summary.total_buy가 종목별 단가 × 수량의 합이 되도록 fix. 기존 인라인 코드가 purchase_price를 수량 미곱산으로 단순 누적해 명세(qty 100 · avg 72000 → 7,200,000)와 어긋났음. API_SPEC.md에 purchase_price 필드 의미 + total_buy 계산식 명시. test 3건 (단가 곱산, avg_price 폴백, 다종목 합산). [F6] insta-trends spec/plan 상단에 "google_trends → youtube_trending" 변경 이력 추가. Google Trends endpoint 폐기로 source 교체된 이력이 본문 검색 시 혼란 주는 문제 차단. 사유 cross-ref: feedback_external_data_sources.md
This commit is contained in:
@@ -142,6 +142,7 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
|
||||
"name": "삼성전자",
|
||||
"quantity": 100,
|
||||
"avg_price": 72000,
|
||||
"purchase_price": 72000,
|
||||
"current_price": 74500,
|
||||
"price_session": "NXT_AFTER",
|
||||
"price_as_of": "2026-05-11T19:21:40+09:00",
|
||||
@@ -159,6 +160,10 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
|
||||
}
|
||||
```
|
||||
|
||||
> **`purchase_price` 필드**: 종목별 매입 단가(1주당). 사용자가 수동 등록한 매입가가
|
||||
> 평균단가(`avg_price`)와 다를 때 표시용으로 분리한다. 미설정 시 `avg_price`로 폴백.
|
||||
> `summary.total_buy = SUM(purchase_price × quantity)` (CODE_REVIEW F4에서 명세 정합화).
|
||||
|
||||
> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다.
|
||||
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.
|
||||
|
||||
|
||||
@@ -354,11 +354,11 @@ def get_portfolio():
|
||||
price_session = detail["session"] if detail else None
|
||||
price_as_of = detail["as_of"] if detail else None
|
||||
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
|
||||
# purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백)
|
||||
# purchase_price: 매입 단가(1주당) — 없으면 avg_price로 폴백 (CODE_REVIEW F4)
|
||||
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"]
|
||||
cost_basis = item["avg_price"] * item["quantity"]
|
||||
# 총 매입 금액 표시는 종목별 매입가의 단순 합계 (수량 미곱산)
|
||||
buy_amount = purchase_price
|
||||
# 총 매입 금액 = 단가 × 보유 수량. API_SPEC.md 예시(qty 100·avg 72000 → 7,200,000)와 일치
|
||||
buy_amount = purchase_price * item["quantity"]
|
||||
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
||||
profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None
|
||||
profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None
|
||||
|
||||
77
stock/tests/test_portfolio_total_buy.py
Normal file
77
stock/tests/test_portfolio_total_buy.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""포트폴리오 /api/portfolio 응답의 total_buy 계산 회귀 테스트 (CODE_REVIEW F4).
|
||||
|
||||
purchase_price는 종목별 단가(1주당) 의미. total_buy = SUM(purchase_price × quantity).
|
||||
purchase_price가 없으면 avg_price로 폴백 후 동일하게 수량 곱산.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _fake_db_setup(monkeypatch, items, cash=None):
|
||||
from app import main as stock_main
|
||||
monkeypatch.setattr(stock_main, "get_all_portfolio", lambda: items)
|
||||
monkeypatch.setattr(stock_main, "get_all_broker_cash", lambda: cash or [])
|
||||
|
||||
|
||||
def test_portfolio_total_buy_uses_purchase_price_times_quantity(monkeypatch):
|
||||
"""purchase_price 설정 시: total_buy = purchase_price × quantity 의 합."""
|
||||
items = [
|
||||
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||
"quantity": 100, "avg_price": 72000, "purchase_price": 70000},
|
||||
]
|
||||
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
|
||||
_fake_db_setup(monkeypatch, items)
|
||||
from app import main as stock_main
|
||||
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/portfolio")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# purchase_price=70000 × quantity=100 = 7,000,000
|
||||
assert data["summary"]["total_buy"] == 7_000_000
|
||||
|
||||
|
||||
def test_portfolio_total_buy_falls_back_to_avg_price_with_quantity(monkeypatch):
|
||||
"""purchase_price 미설정 시: avg_price 폴백 + 수량 곱산. API_SPEC 예시와 일치."""
|
||||
items = [
|
||||
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||
"quantity": 100, "avg_price": 72000, "purchase_price": None},
|
||||
]
|
||||
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
|
||||
_fake_db_setup(monkeypatch, items)
|
||||
from app import main as stock_main
|
||||
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/portfolio")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# avg_price=72000 × quantity=100 = 7,200,000 (API_SPEC.md 예시와 일치)
|
||||
assert data["summary"]["total_buy"] == 7_200_000
|
||||
|
||||
|
||||
def test_portfolio_total_buy_sums_multiple_holdings(monkeypatch):
|
||||
"""여러 종목 합산도 단가 × 수량 합."""
|
||||
items = [
|
||||
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
|
||||
"quantity": 100, "avg_price": 70000, "purchase_price": 70000},
|
||||
{"id": 2, "broker": "NH", "ticker": "000660", "name": "SK하이닉스",
|
||||
"quantity": 50, "avg_price": 130000, "purchase_price": 130000},
|
||||
]
|
||||
fake_prices = {
|
||||
"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
|
||||
"000660": {"price": 140000, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
|
||||
}
|
||||
_fake_db_setup(monkeypatch, items)
|
||||
from app import main as stock_main
|
||||
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/portfolio")
|
||||
data = resp.json()
|
||||
# 70000*100 + 130000*50 = 7,000,000 + 6,500,000 = 13,500,000
|
||||
assert data["summary"]["total_buy"] == 13_500_000
|
||||
Reference in New Issue
Block a user