From de015a24401bd54175bb0022fe0786a2d92b9560 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 15 Apr 2026 01:58:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EB=A7=A4=EC=9E=85=EA=B0=80(purchase=5Fpri?= =?UTF-8?q?ce)=20=EC=BB=AC=EB=9F=BC=20=EB=B6=84=EB=A6=AC=20+=20=EC=9B=90?= =?UTF-8?q?=EB=8B=AC=EB=9F=AC=20=ED=99=98=EC=9C=A8=20=EB=B6=80=ED=98=B8=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원달러 환율: - 네이버 환율 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 --- stock-lab/app/db.py | 23 ++++++++++++++++++----- stock-lab/app/main.py | 29 +++++++++++++++++++++-------- stock-lab/app/scraper.py | 15 +++++++++------ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/stock-lab/app/db.py b/stock-lab/app/db.py index fbb6e4e..a6d85c9 100644 --- a/stock-lab/app/db.py +++ b/stock-lab/app/db.py @@ -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 diff --git a/stock-lab/app/main.py b/stock-lab/app/main.py index fdb9b8c..3db1394 100644 --- a/stock-lab/app/main.py +++ b/stock-lab/app/main.py @@ -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} diff --git a/stock-lab/app/scraper.py b/stock-lab/app/scraper.py index 4b6bbf7..a1f9e04 100644 --- a/stock-lab/app/scraper.py +++ b/stock-lab/app/scraper.py @@ -204,12 +204,15 @@ def fetch_major_indices() -> Dict[str, Any]: blind_txt = usd_item.select_one(".blind").get_text(strip=True) 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": "원달러 환율", "value": value,