feat(stock-lab): 포트폴리오 매입가(purchase_price) 컬럼 분리 + 원달러 환율 부호 보정
원달러 환율: - 네이버 환율 change_value에 부호가 없어 프론트에서 항상 상승으로 인식되던 문제 - direction(red/blue) 기반으로 +/- 부호 prepend 포트폴리오: - portfolio 테이블에 purchase_price 컬럼 추가 (기존 row는 avg_price로 백필) - avg_price(평균단가): 손익률 계산 기준 (cost_basis) - purchase_price(매입가): 총 매입 금액 요약 표시 기준 - API: PortfolioItemRequest/UpdateRequest에 purchase_price(Optional) 추가 - GET /api/portfolio 응답 holdings에 purchase_price 포함, summary.total_buy는 매입가 합계, total_profit_rate는 평균단가 기준 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import hashlib
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
DB_PATH = "/app/data/stock.db"
|
||||
|
||||
@@ -41,11 +41,18 @@ def init_db():
|
||||
name TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
avg_price INTEGER NOT NULL,
|
||||
purchase_price INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
""")
|
||||
|
||||
# 마이그레이션: 기존 DB에 purchase_price 컬럼 없으면 추가 후 avg_price로 백필
|
||||
_pf_cols = {r["name"] for r in conn.execute("PRAGMA table_info(portfolio)").fetchall()}
|
||||
if "purchase_price" not in _pf_cols:
|
||||
conn.execute("ALTER TABLE portfolio ADD COLUMN purchase_price INTEGER")
|
||||
conn.execute("UPDATE portfolio SET purchase_price = avg_price WHERE purchase_price IS NULL")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS broker_cash (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -125,11 +132,17 @@ def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str,
|
||||
|
||||
# --- Portfolio CRUD ---
|
||||
|
||||
def add_portfolio_item(broker: str, ticker: str, name: str, quantity: int, avg_price: int) -> int:
|
||||
def add_portfolio_item(
|
||||
broker: str, ticker: str, name: str, quantity: int, avg_price: int,
|
||||
purchase_price: Optional[int] = None,
|
||||
) -> int:
|
||||
# purchase_price 미입력 시 avg_price로 기본값 설정 (하위호환)
|
||||
if purchase_price is None:
|
||||
purchase_price = avg_price
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO portfolio (broker, ticker, name, quantity, avg_price) VALUES (?, ?, ?, ?, ?)",
|
||||
(broker, ticker, name, quantity, avg_price),
|
||||
"INSERT INTO portfolio (broker, ticker, name, quantity, avg_price, purchase_price) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(broker, ticker, name, quantity, avg_price, purchase_price),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
@@ -147,7 +160,7 @@ def get_portfolio_item(item_id: int) -> Dict[str, Any] | None:
|
||||
|
||||
|
||||
def update_portfolio_item(item_id: int, **kwargs) -> bool:
|
||||
allowed = {"broker", "ticker", "name", "quantity", "avg_price"}
|
||||
allowed = {"broker", "ticker", "name", "quantity", "avg_price", "purchase_price"}
|
||||
fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||
if not fields:
|
||||
return False
|
||||
|
||||
@@ -284,7 +284,8 @@ class PortfolioItemRequest(BaseModel):
|
||||
ticker: str
|
||||
name: str
|
||||
quantity: int
|
||||
avg_price: int
|
||||
avg_price: int # 평균단가 (현재가 평가/손익 계산용)
|
||||
purchase_price: Optional[int] = None # 매입가 (총 매입 금액 계산용, 미입력 시 avg_price로 자동 설정)
|
||||
|
||||
|
||||
class PortfolioUpdateRequest(BaseModel):
|
||||
@@ -293,6 +294,7 @@ class PortfolioUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
quantity: Optional[int] = None
|
||||
avg_price: Optional[int] = None
|
||||
purchase_price: Optional[int] = None
|
||||
|
||||
|
||||
@app.get("/api/portfolio")
|
||||
@@ -320,15 +322,20 @@ def get_portfolio():
|
||||
prices = get_current_prices(tickers)
|
||||
|
||||
holdings = []
|
||||
total_buy = 0
|
||||
total_buy = 0 # 요약 표시용 (purchase_price 기반)
|
||||
total_cost_basis = 0 # 손익률 계산용 (avg_price 기반)
|
||||
total_eval = 0
|
||||
|
||||
for item in items:
|
||||
current_price = prices.get(item["ticker"])
|
||||
buy_amount = item["avg_price"] * item["quantity"]
|
||||
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
|
||||
# purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백)
|
||||
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 * item["quantity"]
|
||||
eval_amount = current_price * item["quantity"] if current_price is not None else None
|
||||
profit_amount = (eval_amount - buy_amount) if eval_amount is not None else None
|
||||
profit_rate = round((profit_amount / buy_amount) * 100, 2) if (profit_amount is not None and buy_amount) 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
|
||||
|
||||
holdings.append({
|
||||
"id": item["id"],
|
||||
@@ -337,6 +344,7 @@ def get_portfolio():
|
||||
"name": item["name"],
|
||||
"quantity": item["quantity"],
|
||||
"avg_price": item["avg_price"],
|
||||
"purchase_price": purchase_price,
|
||||
"current_price": current_price,
|
||||
"eval_amount": eval_amount,
|
||||
"profit_amount": profit_amount,
|
||||
@@ -344,11 +352,13 @@ def get_portfolio():
|
||||
})
|
||||
|
||||
total_buy += buy_amount
|
||||
total_cost_basis += cost_basis
|
||||
if eval_amount is not None:
|
||||
total_eval += eval_amount
|
||||
|
||||
total_profit = total_eval - total_buy
|
||||
total_profit_rate = round((total_profit / total_buy) * 100, 2) if total_buy else 0.0
|
||||
# 손익은 실제 평균단가(cost_basis) 기준으로 계산
|
||||
total_profit = total_eval - total_cost_basis
|
||||
total_profit_rate = round((total_profit / total_cost_basis) * 100, 2) if total_cost_basis else 0.0
|
||||
|
||||
return {
|
||||
"holdings": holdings,
|
||||
@@ -367,7 +377,10 @@ def get_portfolio():
|
||||
@app.post("/api/portfolio", status_code=201)
|
||||
def create_portfolio_item(req: PortfolioItemRequest):
|
||||
"""포트폴리오 종목 추가"""
|
||||
item_id = add_portfolio_item(req.broker, req.ticker, req.name, req.quantity, req.avg_price)
|
||||
item_id = add_portfolio_item(
|
||||
req.broker, req.ticker, req.name, req.quantity, req.avg_price,
|
||||
purchase_price=req.purchase_price,
|
||||
)
|
||||
return {"id": item_id, "ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -205,10 +205,13 @@ def fetch_major_indices() -> Dict[str, Any]:
|
||||
if "상승" in blind_txt: direction = "red"
|
||||
elif "하락" in blind_txt: direction = "blue"
|
||||
|
||||
# 등락률은 리스트에는 안나오고 상세에 나오지만, 여기선 생략하거나 계산 가능.
|
||||
# 일단 UI 통일성을 위해 빈값 혹은 계산된 값 등 처리.
|
||||
# 네이버 메인 환율 영역엔 등락률이 텍스트로 바로 안보임 (title 속성 등에 있을수 있음).
|
||||
# 여기서는 간단히 값만 처리.
|
||||
# change_val은 네이버 HTML에서 부호 없이 숫자만 옴 → direction 기반으로 부호 붙여줌
|
||||
# (프론트 getDirection()이 부호로 색/화살표를 판별하므로)
|
||||
if change_val and not change_val.startswith(("+", "-")):
|
||||
if direction == "red":
|
||||
change_val = f"+{change_val}"
|
||||
elif direction == "blue":
|
||||
change_val = f"-{change_val}"
|
||||
|
||||
indices.append({
|
||||
"name": "원달러 환율",
|
||||
|
||||
Reference in New Issue
Block a user