stock 실계좌 정보 표출 추가
This commit is contained in:
@@ -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/ {
|
||||||
|
|||||||
@@ -27,12 +27,25 @@ def init_db():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_crawled ON articles(crawled_at DESC)")
|
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()}
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()}
|
||||||
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:
|
||||||
@@ -56,12 +69,54 @@ def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str,
|
|||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
if category:
|
if category:
|
||||||
rows = conn.execute(
|
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)
|
(category, limit)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
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,)
|
(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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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