diff --git a/.gitignore b/.gitignore index f338af9..1dd834a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -./.venv -./app/__pycache__/ - +.venv +__pycache__/ *.pyc +.auth_cache.json +config.yaml +.env diff --git a/app/core/auth.py b/app/core/auth.py index f810b1b..fc1436f 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -1,10 +1,9 @@ from __future__ import annotations import asyncio -import hashlib -import hmac -import time +import json from datetime import datetime, timedelta +from pathlib import Path import httpx @@ -17,6 +16,7 @@ logger = setup_logger("auth") _TOKEN_URL = "/oauth2/tokenP" _APPROVAL_URL = "/oauth2/Approval" _HASHKEY_URL = "/uapi/hashkey" +_TOKEN_CACHE_PATH = Path(settings.app.db_path).parent / ".auth_cache.json" class TokenManager: @@ -27,8 +27,41 @@ class TokenManager: self._approval_expires_at: datetime = datetime.min self._rate_limiter = RateLimiter(requests_per_second=1.0) self._lock = asyncio.Lock() - self._client = httpx.AsyncClient(timeout=10.0) + self._client = httpx.AsyncClient(timeout=15.0) self._authenticated: bool = False + self._load_cache() + + def _load_cache(self) -> None: + if not _TOKEN_CACHE_PATH.exists(): + return + try: + data = json.loads(_TOKEN_CACHE_PATH.read_text()) + now = datetime.now() + token_exp = datetime.fromisoformat(data.get("token_expires_at", "")) + if data.get("access_token") and now < token_exp: + self._access_token = data["access_token"] + self._token_expires_at = token_exp + self._authenticated = True + logger.info("캐시에서 토큰 복원 완료 (만료: %s)", token_exp) + approval_exp = datetime.fromisoformat(data.get("approval_expires_at", "")) + if data.get("approval_key") and now < approval_exp: + self._approval_key = data["approval_key"] + self._approval_expires_at = approval_exp + logger.info("캐시에서 approval_key 복원 완료 (만료: %s)", approval_exp) + except Exception as e: + logger.debug("토큰 캐시 로드 실패: %s", e) + + def _save_cache(self) -> None: + try: + _TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + _TOKEN_CACHE_PATH.write_text(json.dumps({ + "access_token": self._access_token, + "token_expires_at": self._token_expires_at.isoformat(), + "approval_key": self._approval_key, + "approval_expires_at": self._approval_expires_at.isoformat(), + })) + except Exception as e: + logger.debug("토큰 캐시 저장 실패: %s", e) @property def is_authenticated(self) -> bool: @@ -49,18 +82,30 @@ class TokenManager: "appkey": settings.kis.app_key, "appsecret": settings.kis.app_secret, } - resp = await self._client.post(url, json=payload) - data = resp.json() - if resp.status_code != 200 or "access_token" not in data: - logger.warning("토큰 발급 실패: %s", data.get("message", resp.status_code)) + for attempt in range(3): + resp = await self._client.post(url, json=payload) + data = resp.json() + + if data.get("error_code") == "EGW00133": + wait = 65 * (attempt + 1) + logger.warning("토큰 발급 1분 제한 - %d초 후 재시도 (%d/3)", wait, attempt + 1) + await asyncio.sleep(wait) + continue + + if resp.status_code != 200 or "access_token" not in data: + logger.warning("토큰 발급 실패: %s", data.get("message", resp.status_code)) + return + + self._access_token = data["access_token"] + expires_in = int(data.get("expires_in", 7776000)) + self._token_expires_at = datetime.now() + timedelta(seconds=expires_in) + self._authenticated = True + self._save_cache() + logger.info("REST access_token 발급 완료 (만료: %s)", self._token_expires_at) return - self._access_token = data["access_token"] - expires_in = int(data.get("expires_in", 7776000)) - self._token_expires_at = datetime.now() + timedelta(seconds=expires_in) - self._authenticated = True - logger.info("REST access_token 발급 완료 (만료: %s)", self._token_expires_at) + logger.error("토큰 발급 3회 모두 실패 (1분 제한)") async def _request_approval_key(self) -> None: if not settings.kis.app_key or not settings.kis.app_secret: @@ -72,16 +117,30 @@ class TokenManager: "appkey": settings.kis.app_key, "secretkey": settings.kis.app_secret, } - resp = await self._client.post(url, json=payload) - data = resp.json() - if resp.status_code != 200 or "approval_key" not in data: - logger.warning("approval_key 발급 실패: %s", data.get("message", resp.status_code)) + for attempt in range(3): + resp = await self._client.post(url, json=payload) + data = resp.json() + + if data.get("error_code") == "EGW00133": + wait = 65 * (attempt + 1) + logger.warning( + "approval_key 발급 1분 제한 - %d초 후 재시도 (%d/3)", wait, attempt + 1 + ) + await asyncio.sleep(wait) + continue + + if resp.status_code != 200 or "approval_key" not in data: + logger.warning("approval_key 발급 실패: %s", data.get("message", resp.status_code)) + return + + self._approval_key = data["approval_key"] + self._approval_expires_at = datetime.now() + timedelta(hours=23, minutes=50) + self._save_cache() + logger.info("WebSocket approval_key 발급 완료 (만료: %s)", self._approval_expires_at) return - self._approval_key = data["approval_key"] - self._approval_expires_at = datetime.now() + timedelta(hours=23, minutes=50) - logger.info("WebSocket approval_key 발급 완료 (만료: %s)", self._approval_expires_at) + logger.error("approval_key 발급 3회 모두 실패 (1분 제한)") async def get_access_token(self) -> str: async with self._lock: diff --git a/app/core/config.py b/app/core/config.py index 8a40dba..5e0ef38 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,33 +1,28 @@ from __future__ import annotations -import os from pathlib import Path from typing import Any import yaml -from pydantic import Field -from pydantic_settings import BaseSettings -class KISConfig(BaseSettings): - app_key: str = Field(default="", alias="KIS_APP_KEY") - app_secret: str = Field(default="", alias="KIS_APP_SECRET") - account_no: str = Field(default="", alias="KIS_ACCOUNT_NO") - account_code: str = Field(default="01", alias="KIS_ACCOUNT_CODE") - hts_id: str = Field(default="", alias="KIS_HTS_ID") - server_mode: str = Field(default="vps", alias="KIS_SERVER_MODE") - - model_config = {"env_file": ".env", "extra": "ignore"} +class KISConfig: + app_key: str = "" + app_secret: str = "" + account_no: str = "" + account_code: str = "01" + hts_id: str = "" + server_mode: str = "vps" -class AppConfig(BaseSettings): - host: str = Field(default="0.0.0.0", alias="APP_HOST") - port: int = Field(default=8000, alias="APP_PORT") - debug: bool = Field(default=False, alias="APP_DEBUG") - db_path: str = Field(default="./data/stock.db", alias="DB_PATH") - log_level: str = Field(default="INFO", alias="LOG_LEVEL") - - model_config = {"env_file": ".env", "extra": "ignore"} +class AppConfig: + name: str = "StockAutomation" + version: str = "0.1.0" + host: str = "0.0.0.0" + port: int = 8000 + debug: bool = False + db_path: str = "./data/stock.db" + log_level: str = "INFO" class CollectorConfig: @@ -72,9 +67,18 @@ class Settings: with open(yaml_path, encoding="utf-8") as f: data: dict[str, Any] = yaml.safe_load(f) or {} + app = data.get("app", {}) + for key, val in app.items(): + if hasattr(self.app, key): + setattr(self.app, key, val) + kis = data.get("kis", {}) - if "server_mode" in kis: - self.kis.server_mode = kis["server_mode"] + for key, val in kis.items(): + if key == "rate_limit": + continue + if hasattr(self.kis, key): + setattr(self.kis, key, val) + rl = kis.get("rate_limit", {}) if "requests_per_second" in rl: self.rate_limit.requests_per_second = rl["requests_per_second"] diff --git a/app/main.py b/app/main.py index a2cf8c0..0ff4076 100644 --- a/app/main.py +++ b/app/main.py @@ -44,6 +44,11 @@ async def lifespan(app: FastAPI): if token_manager.is_authenticated: start_scheduler() + try: + await realtime_service.connect() + logger.info("KIS WebSocket 연결 완료") + except Exception as e: + logger.warning("KIS WebSocket 연결 실패: %s", e) else: logger.info("인증 실패 - 스케줄러 미시작 (인증 후 수동 시작 가능)") diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 861d629..a893e0e 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -5,22 +5,59 @@ from sqlalchemy.orm import Session from app.core.auth import token_manager from app.core.database import get_db -from app.models.stock import Holding, PriceHistory, Strategy, Trade, Stock +from app.models.stock import Holding, Strategy, Trade, Stock from app.engine.scheduler import scheduler +from app.services.account import account_service +from app.services.realtime import realtime_service router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) @router.get("/") -def dashboard(db: Session = Depends(get_db)) -> dict: - holdings = db.query(Holding).all() +async def dashboard(db: Session = Depends(get_db)) -> dict: recent_trades = db.query(Trade).order_by(Trade.created_at.desc()).limit(20).all() active_strategies = db.query(Strategy).filter(Strategy.is_active == True).all() active_stocks = db.query(Stock).filter(Stock.is_active == True).all() - total_profit = sum(h.profit for h in holdings) - total_invested = sum(h.avg_price * h.qty for h in holdings if h.avg_price > 0) - total_evaluated = sum(h.current_price * h.qty for h in holdings if h.current_price > 0) + holdings = [] + total_profit = 0.0 + total_invested = 0.0 + total_evaluated = 0.0 + + if token_manager.is_authenticated: + try: + balance = await account_service.get_balance() + for s in balance.get("stocks", []): + holdings.append({ + "stock_code": s["stock_code"], + "stock_name": s["stock_name"], + "qty": s["qty"], + "avg_price": s["avg_price"], + "current_price": s["current_price"], + "profit": s["profit"], + "profit_rate": s["profit_rate"], + }) + total_invested += s.get("buy_amount", s["avg_price"] * s["qty"]) + total_evaluated += s.get("eval_amount", s["current_price"] * s["qty"]) + total_profit += s["profit"] + except Exception: + pass + + if not holdings: + local = db.query(Holding).all() + for h in local: + holdings.append({ + "stock_code": h.stock_code, + "stock_name": h.stock_name, + "qty": h.qty, + "avg_price": h.avg_price, + "current_price": h.current_price, + "profit": h.profit, + "profit_rate": h.profit_rate, + }) + total_invested += h.avg_price * h.qty + total_evaluated += h.current_price * h.qty + total_profit += h.profit jobs = [] if scheduler.running: @@ -42,18 +79,7 @@ def dashboard(db: Session = Depends(get_db)) -> dict: "active_strategies": len(active_strategies), "active_stocks": len(active_stocks), }, - "holdings": [ - { - "stock_code": h.stock_code, - "stock_name": h.stock_name, - "qty": h.qty, - "avg_price": h.avg_price, - "current_price": h.current_price, - "profit": h.profit, - "profit_rate": h.profit_rate, - } - for h in holdings - ], + "holdings": holdings, "recent_trades": [ { "id": t.id, @@ -68,3 +94,11 @@ def dashboard(db: Session = Depends(get_db)) -> dict: ], "scheduler_jobs": jobs, } + + +@router.get("/status") +def get_status() -> dict: + return { + "authenticated": token_manager.is_authenticated, + "realtime_connected": realtime_service.is_connected, + } diff --git a/app/routers/stocks.py b/app/routers/stocks.py index 74193a8..d573f7a 100644 --- a/app/routers/stocks.py +++ b/app/routers/stocks.py @@ -1,5 +1,6 @@ from __future__ import annotations +import httpx from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -63,7 +64,10 @@ def remove_stock(stock_code: str, db: Session = Depends(get_db)) -> dict: @router.get("/{stock_code}/price") async def get_price(stock_code: str) -> dict: - price = await market_data_service.get_current_price(stock_code) + try: + price = await market_data_service.get_current_price(stock_code) + except (httpx.TimeoutException, httpx.ConnectError): + raise HTTPException(status_code=504, detail="KIS 서버 응답 시간 초과") if not price: raise HTTPException(status_code=404, detail="시세 조회 실패") return price @@ -75,5 +79,19 @@ async def get_chart(stock_code: str, days: int = 30) -> list[dict]: end = datetime.now().strftime("%Y%m%d") start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d") - chart = await market_data_service.get_daily_chart(stock_code, start, end, days) + try: + chart = await market_data_service.get_daily_chart(stock_code, start, end, days) + except (httpx.TimeoutException, httpx.ConnectError): + raise HTTPException(status_code=504, detail="KIS 서버 응답 시간 초과") return chart + + +@router.get("/{stock_code}/orderbook") +async def get_orderbook(stock_code: str) -> dict: + try: + orderbook = await market_data_service.get_orderbook(stock_code) + except (httpx.TimeoutException, httpx.ConnectError): + raise HTTPException(status_code=504, detail="KIS 서버 응답 시간 초과") + if not orderbook: + raise HTTPException(status_code=404, detail="호가 조회 실패") + return orderbook diff --git a/app/services/market_data.py b/app/services/market_data.py index 6c9d88a..5b4dc1b 100644 --- a/app/services/market_data.py +++ b/app/services/market_data.py @@ -19,7 +19,7 @@ _FID_COND_MRKT_DIV_CODE = "FID_COND_MRKT_DIV_CODE" class MarketDataService: def __init__(self) -> None: - self._client = httpx.AsyncClient(timeout=10.0) + self._client = httpx.AsyncClient(timeout=30.0) self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second) def _check_auth(self) -> bool: diff --git a/app/services/realtime.py b/app/services/realtime.py index 942fdce..bc8e6c4 100644 --- a/app/services/realtime.py +++ b/app/services/realtime.py @@ -30,6 +30,10 @@ class RealtimeService: self._heartbeat_task: asyncio.Task | None = None self._receive_task: asyncio.Task | None = None + @property + def is_connected(self) -> bool: + return self._ws is not None and self._running + def on(self, event: str, callback: Callable) -> None: self._callbacks.setdefault(event, []).append(callback) @@ -40,7 +44,7 @@ class RealtimeService: try: self._ws = await websockets.connect( url, - extra_headers={"approval_key": approval_key, "type": "Y"}, + additional_headers={"approval_key": approval_key, "type": "Y"}, ) self._running = True self._receive_task = asyncio.create_task(self._receive_loop()) @@ -106,11 +110,14 @@ class RealtimeService: await self._handle_message(raw_msg) except websockets.ConnectionClosed: logger.warning("WebSocket 연결 끊김. 5초 후 재연결...") - if self._running: - await asyncio.sleep(5) - await self.connect() except Exception as e: logger.error("WebSocket 수신 오류: %s", e) + if self._running: + await asyncio.sleep(5) + try: + await self.connect() + except Exception: + logger.error("WebSocket 재연결 실패") async def _handle_message(self, raw_msg: str) -> None: try: diff --git a/app/templates/index.html b/app/templates/index.html index 60d9632..51e042d 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -60,6 +60,19 @@ .filter-row { display: flex; gap: 12px; align-items: end; margin-bottom: 16px; flex-wrap: wrap; } .positive { color: #3fb950; } .negative { color: #f85149; } + .stock-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; } + .stock-header .name { font-size: 22px; font-weight: 700; color: #e1e4e8; } + .stock-header .code { font-size: 14px; color: #8b949e; } + .stock-price-main { font-size: 32px; font-weight: 700; margin-bottom: 4px; } + .stock-change { font-size: 15px; margin-bottom: 20px; } + .info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } + .orderbook-table { width: 100%; } + .orderbook-table td { text-align: right; padding: 5px 10px; font-size: 13px; font-variant-numeric: tabular-nums; } + .orderbook-table td:first-child { text-align: center; color: #8b949e; width: 40px; } + .orderbook-table .ask-row td { color: #f85149; } + .orderbook-table .bid-row td { color: #3fb950; } + .orderbook-table .mid-row td { color: #8b949e; font-weight: 600; border-top: 1px solid #30363d; border-bottom: 1px solid #30363d; } + .chart-canvas { width: 100%; height: 300px; background: #0d1117; border-radius: 6px; margin-top: 12px; }
@@ -74,6 +87,9 @@ 📋 과거 기록 조회 + + 📈 종목 조회 + @@ -91,7 +107,7 @@