stock 실계좌 정보 표출 추가

This commit is contained in:
2026-02-25 23:49:28 +09:00
parent 71d9d7a571
commit ea9eb749aa
4 changed files with 236 additions and 4 deletions

View File

@@ -74,6 +74,16 @@ server {
proxy_pass http://stock-lab:8000/api/trade/; 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/ 중복 제거) # API 프록시 (여기가 포인트: /api/ 중복 제거)
location /api/ { location /api/ {

View File

@@ -33,6 +33,19 @@ def init_db():
if "category" not in cols: if "category" not in cols:
conn.execute("ALTER TABLE articles ADD COLUMN category TEXT DEFAULT 'domestic'") 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: def save_articles(articles: List[Dict[str, str]]) -> int:
count = 0 count = 0
with _conn() as conn: with _conn() as conn:
@@ -65,3 +78,45 @@ def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str,
(limit,) (limit,)
).fetchall() ).fetchall()
return [dict(r) for r in rows] 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

View File

@@ -8,8 +8,13 @@ import requests
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from pydantic import BaseModel 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 .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
from .price_fetcher import get_current_prices
app = FastAPI() app = FastAPI()
@@ -130,3 +135,97 @@ def version():
return {"version": os.getenv("APP_VERSION", "dev")} 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}

View File

@@ -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