stock 실계좌 정보 표출 추가
This commit is contained in:
@@ -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/ {
|
||||
|
||||
@@ -33,6 +33,19 @@ def init_db():
|
||||
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:
|
||||
@@ -65,3 +78,45 @@ def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str,
|
||||
(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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
68
stock-lab/app/price_fetcher.py
Normal file
68
stock-lab/app/price_fetcher.py
Normal 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
|
||||
Reference in New Issue
Block a user