feat: config.yaml 기반 설정 마이그레이션 및 대시보드/종목조회 기능 추가

- config.yaml에서 직접 KIS 설정 관리 (BaseSettings 제거)
- 대시보드 KIS 계좌 잔고 실시간 연동, 로컬 DB 폴백
- 종목 조회 페이지: 시세/호가/일봉차트 (canvas) 구현
- 호가 REST API 엔드포인트 (/api/stocks/{code}/orderbook) 추가
- 토큰 캐시(.auth_cache.json) 저장/복원, 1분 재시도 제한
- KIS WebSocket 자동 연결 + 재연결 로직
- httpx 타임아웃 10초→30초, 라우터 타임아웃 핸들링 (504)
- rate limit: requests_per_second 2.0 (모의투자 초당 2건)
- 사이드바 3메뉴: 실거래 / 과거기록 / 종목조회
This commit is contained in:
2026-07-17 15:25:34 +09:00
parent d3f3931c38
commit 182c2b38e6
10 changed files with 419 additions and 85 deletions
+79 -20
View File
@@ -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:
+26 -22
View File
@@ -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"]