diff --git a/nginx/default.conf b/nginx/default.conf index 4194b92..80da544 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -74,6 +74,16 @@ server { proxy_pass http://stock-lab:8000/api/trade/; } + # portfolio API (Stock Lab) + location /api/portfolio/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://stock-lab:8000/api/portfolio/; + } + # API 프록시 (여기가 포인트: /api/ 중복 제거) location /api/ { diff --git a/stock-lab/app/db.py b/stock-lab/app/db.py index 85c29c9..7411eba 100644 --- a/stock-lab/app/db.py +++ b/stock-lab/app/db.py @@ -27,12 +27,25 @@ def init_db(): ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_crawled ON articles(crawled_at DESC)") - + # 컬럼 추가 (기존 테이블 마이그레이션) cols = {r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()} if "category" not in cols: conn.execute("ALTER TABLE articles ADD COLUMN category TEXT DEFAULT 'domestic'") + conn.execute(""" + CREATE TABLE IF NOT EXISTS portfolio ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + broker TEXT NOT NULL, + ticker TEXT NOT NULL, + name TEXT NOT NULL, + quantity INTEGER NOT NULL, + avg_price INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + def save_articles(articles: List[Dict[str, str]]) -> int: count = 0 with _conn() as conn: @@ -56,12 +69,54 @@ def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str, with _conn() as conn: if category: rows = conn.execute( - "SELECT * FROM articles WHERE category = ? ORDER BY crawled_at DESC, id DESC LIMIT ?", + "SELECT * FROM articles WHERE category = ? ORDER BY crawled_at DESC, id DESC LIMIT ?", (category, limit) ).fetchall() else: rows = conn.execute( - "SELECT * FROM articles ORDER BY crawled_at DESC, id DESC LIMIT ?", + "SELECT * FROM articles ORDER BY crawled_at DESC, id DESC LIMIT ?", (limit,) ).fetchall() return [dict(r) for r in rows] + + +# --- Portfolio CRUD --- + +def add_portfolio_item(broker: str, ticker: str, name: str, quantity: int, avg_price: int) -> int: + with _conn() as conn: + cur = conn.execute( + "INSERT INTO portfolio (broker, ticker, name, quantity, avg_price) VALUES (?, ?, ?, ?, ?)", + (broker, ticker, name, quantity, avg_price), + ) + return cur.lastrowid + + +def get_all_portfolio() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute("SELECT * FROM portfolio ORDER BY id").fetchall() + return [dict(r) for r in rows] + + +def get_portfolio_item(item_id: int) -> Dict[str, Any] | None: + with _conn() as conn: + row = conn.execute("SELECT * FROM portfolio WHERE id = ?", (item_id,)).fetchone() + return dict(row) if row else None + + +def update_portfolio_item(item_id: int, **kwargs) -> bool: + allowed = {"broker", "ticker", "name", "quantity", "avg_price"} + fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None} + if not fields: + return False + fields["updated_at"] = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S") + set_clause = ", ".join(f"{k} = ?" for k in fields) + values = list(fields.values()) + [item_id] + with _conn() as conn: + cur = conn.execute(f"UPDATE portfolio SET {set_clause} WHERE id = ?", values) + return cur.rowcount > 0 + + +def delete_portfolio_item(item_id: int) -> bool: + with _conn() as conn: + cur = conn.execute("DELETE FROM portfolio WHERE id = ?", (item_id,)) + return cur.rowcount > 0 diff --git a/stock-lab/app/main.py b/stock-lab/app/main.py index 31f5150..442278f 100644 --- a/stock-lab/app/main.py +++ b/stock-lab/app/main.py @@ -8,8 +8,13 @@ import requests from apscheduler.schedulers.background import BackgroundScheduler from pydantic import BaseModel -from .db import init_db, save_articles, get_latest_articles +from .db import ( + init_db, save_articles, get_latest_articles, + add_portfolio_item, get_all_portfolio, get_portfolio_item, + update_portfolio_item, delete_portfolio_item, +) from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news +from .price_fetcher import get_current_prices app = FastAPI() @@ -130,3 +135,97 @@ def version(): return {"version": os.getenv("APP_VERSION", "dev")} +# --- Portfolio API --- + +class PortfolioItemRequest(BaseModel): + broker: str + ticker: str + name: str + quantity: int + avg_price: int + + +class PortfolioUpdateRequest(BaseModel): + broker: Optional[str] = None + ticker: Optional[str] = None + name: Optional[str] = None + quantity: Optional[int] = None + avg_price: Optional[int] = None + + +@app.get("/api/portfolio") +def get_portfolio(): + """전체 포트폴리오 조회 (현재가 + 손익 포함)""" + items = get_all_portfolio() + if not items: + return {"holdings": [], "summary": {"total_buy": 0, "total_eval": 0, "total_profit": 0, "total_profit_rate": 0.0}} + + tickers = list({item["ticker"] for item in items}) + prices = get_current_prices(tickers) + + holdings = [] + total_buy = 0 + total_eval = 0 + + for item in items: + current_price = prices.get(item["ticker"]) + buy_amount = item["avg_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 + + holdings.append({ + "id": item["id"], + "broker": item["broker"], + "ticker": item["ticker"], + "name": item["name"], + "quantity": item["quantity"], + "avg_price": item["avg_price"], + "current_price": current_price, + "eval_amount": eval_amount, + "profit_amount": profit_amount, + "profit_rate": profit_rate, + }) + + total_buy += buy_amount + 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 + + return { + "holdings": holdings, + "summary": { + "total_buy": total_buy, + "total_eval": total_eval, + "total_profit": total_profit, + "total_profit_rate": total_profit_rate, + }, + } + + +@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) + return {"id": item_id, "ok": True} + + +@app.put("/api/portfolio/{item_id}") +def update_portfolio(item_id: int, req: PortfolioUpdateRequest): + """포트폴리오 종목 수정""" + if get_portfolio_item(item_id) is None: + return JSONResponse(status_code=404, content={"error": "Item not found"}) + update_portfolio_item(item_id, **req.dict()) + return {"ok": True} + + +@app.delete("/api/portfolio/{item_id}") +def delete_portfolio(item_id: int): + """포트폴리오 종목 삭제""" + if not delete_portfolio_item(item_id): + return JSONResponse(status_code=404, content={"error": "Item not found"}) + return {"ok": True} + + diff --git a/stock-lab/app/price_fetcher.py b/stock-lab/app/price_fetcher.py new file mode 100644 index 0000000..7d87b8b --- /dev/null +++ b/stock-lab/app/price_fetcher.py @@ -0,0 +1,68 @@ +import time +import requests +from bs4 import BeautifulSoup +from typing import Optional + +_cache: dict[str, tuple[Optional[int], float]] = {} # ticker -> (price, timestamp) +_CACHE_TTL = 180 # 3분 + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/90.0.4430.93 Safari/537.36" + ) +} + + +def _fetch_from_mobile_api(ticker: str) -> Optional[int]: + """네이버 모바일 주식 API로 현재가 조회""" + url = f"https://m.stock.naver.com/api/stock/{ticker}/basic" + try: + resp = requests.get(url, headers=_HEADERS, timeout=5) + resp.raise_for_status() + data = resp.json() + price_str = data.get("closePrice") or data.get("stockEndPrice") or "" + price_str = str(price_str).replace(",", "").strip() + return int(price_str) if price_str.isdigit() else None + except Exception: + return None + + +def _fetch_from_html_fallback(ticker: str) -> Optional[int]: + """네이버 금융 HTML 폴백 (.no_today .blind 파싱)""" + url = f"https://finance.naver.com/item/main.naver?code={ticker}" + try: + resp = requests.get(url, headers=_HEADERS, timeout=5) + resp.raise_for_status() + soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949") + tag = soup.select_one(".no_today .blind") + if tag: + price_str = tag.get_text(strip=True).replace(",", "") + return int(price_str) if price_str.isdigit() else None + return None + except Exception: + return None + + +def get_current_price(ticker: str) -> Optional[int]: + """단건 현재가 조회 (3분 캐시)""" + now = time.time() + cached = _cache.get(ticker) + if cached and (now - cached[1]) < _CACHE_TTL: + return cached[0] + + price = _fetch_from_mobile_api(ticker) + if price is None: + price = _fetch_from_html_fallback(ticker) + + _cache[ticker] = (price, now) + return price + + +def get_current_prices(tickers: list[str]) -> dict[str, Optional[int]]: + """배치 현재가 조회 (캐시 미스 종목만 실제 호출)""" + result: dict[str, Optional[int]] = {} + for ticker in tickers: + result[ticker] = get_current_price(ticker) + return result