diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d9cf1eb Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..04e3c00 Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/core/__pycache__/__init__.cpython-313.pyc b/app/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a2afb90 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/core/__pycache__/auth.cpython-313.pyc b/app/core/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..642a478 Binary files /dev/null and b/app/core/__pycache__/auth.cpython-313.pyc differ diff --git a/app/core/__pycache__/config.cpython-313.pyc b/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..98ed851 Binary files /dev/null and b/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/app/core/__pycache__/database.cpython-313.pyc b/app/core/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..08551a6 Binary files /dev/null and b/app/core/__pycache__/database.cpython-313.pyc differ diff --git a/app/core/__pycache__/logger.cpython-313.pyc b/app/core/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..d071661 Binary files /dev/null and b/app/core/__pycache__/logger.cpython-313.pyc differ diff --git a/app/core/__pycache__/rate_limiter.cpython-313.pyc b/app/core/__pycache__/rate_limiter.cpython-313.pyc new file mode 100644 index 0000000..b036b57 Binary files /dev/null and b/app/core/__pycache__/rate_limiter.cpython-313.pyc differ diff --git a/app/core/auth.py b/app/core/auth.py index 10bcd41..f810b1b 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -28,12 +28,21 @@ class TokenManager: self._rate_limiter = RateLimiter(requests_per_second=1.0) self._lock = asyncio.Lock() self._client = httpx.AsyncClient(timeout=10.0) + self._authenticated: bool = False + + @property + def is_authenticated(self) -> bool: + return self._authenticated @property def is_vps(self) -> bool: return settings.kis.server_mode == "vps" async def _request_token(self) -> None: + if not settings.kis.app_key or not settings.kis.app_secret: + logger.debug("API 키가 설정되지 않음") + return + url = f"{get_base_url()}{_TOKEN_URL}" payload = { "grant_type": "client_credentials", @@ -41,15 +50,22 @@ class TokenManager: "appsecret": settings.kis.app_secret, } resp = await self._client.post(url, json=payload) - resp.raise_for_status() data = resp.json() + 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 logger.info("REST access_token 발급 완료 (만료: %s)", self._token_expires_at) async def _request_approval_key(self) -> None: + if not settings.kis.app_key or not settings.kis.app_secret: + return + url = f"{get_base_url()}{_APPROVAL_URL}" payload = { "grant_type": "client_credentials", @@ -57,9 +73,12 @@ class TokenManager: "secretkey": settings.kis.app_secret, } resp = await self._client.post(url, json=payload) - resp.raise_for_status() 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)) + 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) @@ -85,7 +104,8 @@ class TokenManager: "appsecret": settings.kis.app_secret, } resp = await self._client.post(url, json=data, headers=headers) - resp.raise_for_status() + if resp.status_code != 200: + raise RuntimeError(f"hashkey 생성 실패: {resp.status_code}") return resp.json()["HASH"] def get_auth_headers(self, tr_id: str) -> dict[str, str]: diff --git a/app/engine/__pycache__/__init__.cpython-313.pyc b/app/engine/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..809df23 Binary files /dev/null and b/app/engine/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/engine/__pycache__/collector.cpython-313.pyc b/app/engine/__pycache__/collector.cpython-313.pyc new file mode 100644 index 0000000..7e5f307 Binary files /dev/null and b/app/engine/__pycache__/collector.cpython-313.pyc differ diff --git a/app/engine/__pycache__/scheduler.cpython-313.pyc b/app/engine/__pycache__/scheduler.cpython-313.pyc new file mode 100644 index 0000000..1130af1 Binary files /dev/null and b/app/engine/__pycache__/scheduler.cpython-313.pyc differ diff --git a/app/engine/__pycache__/strategy_engine.cpython-313.pyc b/app/engine/__pycache__/strategy_engine.cpython-313.pyc new file mode 100644 index 0000000..fb28290 Binary files /dev/null and b/app/engine/__pycache__/strategy_engine.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/__init__.cpython-313.pyc b/app/engine/strategies/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5771c35 Binary files /dev/null and b/app/engine/strategies/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/base.cpython-313.pyc b/app/engine/strategies/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..7aa2d8c Binary files /dev/null and b/app/engine/strategies/__pycache__/base.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/conditional.cpython-313.pyc b/app/engine/strategies/__pycache__/conditional.cpython-313.pyc new file mode 100644 index 0000000..0e931a4 Binary files /dev/null and b/app/engine/strategies/__pycache__/conditional.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/periodic.cpython-313.pyc b/app/engine/strategies/__pycache__/periodic.cpython-313.pyc new file mode 100644 index 0000000..4b87641 Binary files /dev/null and b/app/engine/strategies/__pycache__/periodic.cpython-313.pyc differ diff --git a/app/engine/strategies/__pycache__/technical.cpython-313.pyc b/app/engine/strategies/__pycache__/technical.cpython-313.pyc new file mode 100644 index 0000000..bdb5094 Binary files /dev/null and b/app/engine/strategies/__pycache__/technical.cpython-313.pyc differ diff --git a/app/main.py b/app/main.py index 6ea1199..a2cf8c0 100644 --- a/app/main.py +++ b/app/main.py @@ -32,11 +32,20 @@ async def lifespan(app: FastAPI): try: await token_manager.get_access_token() - logger.info("KIS 인증 완료") + if token_manager.is_authenticated: + logger.info("KIS 인증 완료") + else: + logger.warning( + "KIS 인증 실패 - API 키를 확인하세요 (.env 파일). " + "UI는 표시되지만 시세 조회/매매 기능은 작동하지 않습니다." + ) except Exception as e: - logger.warning("KIS 인증 실패 (API 키를 확인하세요): %s", e) + logger.warning("KIS 인증 오류: %s", e) - start_scheduler() + if token_manager.is_authenticated: + start_scheduler() + else: + logger.info("인증 실패 - 스케줄러 미시작 (인증 후 수동 시작 가능)") yield @@ -67,7 +76,7 @@ templates = Jinja2Templates(directory="app/templates") @app.get("/", response_class=HTMLResponse) async def index(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) + return templates.TemplateResponse(request, "index.html") @app.get("/health") diff --git a/app/models/__pycache__/__init__.cpython-313.pyc b/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..62e09d4 Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/models/__pycache__/stock.cpython-313.pyc b/app/models/__pycache__/stock.cpython-313.pyc new file mode 100644 index 0000000..0726163 Binary files /dev/null and b/app/models/__pycache__/stock.cpython-313.pyc differ diff --git a/app/routers/__pycache__/__init__.cpython-313.pyc b/app/routers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..65ea507 Binary files /dev/null and b/app/routers/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/routers/__pycache__/dashboard.cpython-313.pyc b/app/routers/__pycache__/dashboard.cpython-313.pyc new file mode 100644 index 0000000..727a67a Binary files /dev/null and b/app/routers/__pycache__/dashboard.cpython-313.pyc differ diff --git a/app/routers/__pycache__/stocks.cpython-313.pyc b/app/routers/__pycache__/stocks.cpython-313.pyc new file mode 100644 index 0000000..c2fdf61 Binary files /dev/null and b/app/routers/__pycache__/stocks.cpython-313.pyc differ diff --git a/app/routers/__pycache__/strategies.cpython-313.pyc b/app/routers/__pycache__/strategies.cpython-313.pyc new file mode 100644 index 0000000..368de5a Binary files /dev/null and b/app/routers/__pycache__/strategies.cpython-313.pyc differ diff --git a/app/routers/__pycache__/trading.cpython-313.pyc b/app/routers/__pycache__/trading.cpython-313.pyc new file mode 100644 index 0000000..882fd04 Binary files /dev/null and b/app/routers/__pycache__/trading.cpython-313.pyc differ diff --git a/app/routers/__pycache__/websocket.cpython-313.pyc b/app/routers/__pycache__/websocket.cpython-313.pyc new file mode 100644 index 0000000..523aa4c Binary files /dev/null and b/app/routers/__pycache__/websocket.cpython-313.pyc differ diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index e1e0258..861d629 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends 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.engine.scheduler import scheduler @@ -31,6 +32,7 @@ def dashboard(db: Session = Depends(get_db)) -> dict: }) return { + "authenticated": token_manager.is_authenticated, "summary": { "total_holdings": len(holdings), "total_profit": total_profit, diff --git a/app/services/__pycache__/__init__.cpython-313.pyc b/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..6cf3df8 Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/services/__pycache__/account.cpython-313.pyc b/app/services/__pycache__/account.cpython-313.pyc new file mode 100644 index 0000000..84d7e58 Binary files /dev/null and b/app/services/__pycache__/account.cpython-313.pyc differ diff --git a/app/services/__pycache__/market_data.cpython-313.pyc b/app/services/__pycache__/market_data.cpython-313.pyc new file mode 100644 index 0000000..9347c6e Binary files /dev/null and b/app/services/__pycache__/market_data.cpython-313.pyc differ diff --git a/app/services/__pycache__/realtime.cpython-313.pyc b/app/services/__pycache__/realtime.cpython-313.pyc new file mode 100644 index 0000000..4bc7932 Binary files /dev/null and b/app/services/__pycache__/realtime.cpython-313.pyc differ diff --git a/app/services/__pycache__/trading.cpython-313.pyc b/app/services/__pycache__/trading.cpython-313.pyc new file mode 100644 index 0000000..276a60e Binary files /dev/null and b/app/services/__pycache__/trading.cpython-313.pyc differ diff --git a/app/services/account.py b/app/services/account.py index 24a0135..a9765b3 100644 --- a/app/services/account.py +++ b/app/services/account.py @@ -20,9 +20,17 @@ class AccountService: self._client = httpx.AsyncClient(timeout=10.0) self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second) + def _check_auth(self) -> bool: + if not token_manager.is_authenticated: + logger.debug("API 인증되지 않음 - 잔고 조회 불가") + return False + return True + async def get_balance(self) -> dict: + if not self._check_auth(): + return {"deposits": [], "stocks": []} + await self._rate_limiter.acquire() - token = await token_manager.get_access_token() tr_id = _BALANCE_TR_IDS.get(settings.kis.server_mode, "VTTC0311R") headers = token_manager.get_auth_headers(tr_id) @@ -71,8 +79,10 @@ class AccountService: return {"deposits": deposits, "stocks": stocks} async def get_orderable_amount(self, stock_code: str, price: int) -> dict: + if not self._check_auth(): + return {"orderable_amount": 0, "orderable_qty": 0} + await self._rate_limiter.acquire() - token = await token_manager.get_access_token() headers = token_manager.get_auth_headers("VTTC0830R" if settings.kis.server_mode == "vps" else "TTTC0830R") params = { diff --git a/app/services/market_data.py b/app/services/market_data.py index 98ce19d..6c9d88a 100644 --- a/app/services/market_data.py +++ b/app/services/market_data.py @@ -22,9 +22,17 @@ class MarketDataService: self._client = httpx.AsyncClient(timeout=10.0) self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second) + def _check_auth(self) -> bool: + if not token_manager.is_authenticated: + logger.debug("API 인증되지 않음 - 시세 조회 불가") + return False + return True + async def get_current_price(self, stock_code: str) -> dict: + if not self._check_auth(): + return {} + await self._rate_limiter.acquire() - token = await token_manager.get_access_token() headers = token_manager.get_auth_headers("FHKST01010100") params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code} url = f"{get_base_url()}{_PRICE_URL}" @@ -54,8 +62,10 @@ class MarketDataService: async def get_daily_chart( self, stock_code: str, start_date: str, end_date: str, count: int = 30 ) -> list[dict]: + if not self._check_auth(): + return [] + await self._rate_limiter.acquire() - token = await token_manager.get_access_token() headers = token_manager.get_auth_headers("FHKST03010200") params = { _FID_COND_MRKT_DIV_CODE: "J", @@ -88,8 +98,10 @@ class MarketDataService: return result async def get_orderbook(self, stock_code: str) -> dict: + if not self._check_auth(): + return {} + await self._rate_limiter.acquire() - token = await token_manager.get_access_token() headers = token_manager.get_auth_headers("FHKST01010200") params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code} url = f"{get_base_url()}{_ORDERBOOK_URL}" diff --git a/app/services/trading.py b/app/services/trading.py index 09831f2..7aec2d5 100644 --- a/app/services/trading.py +++ b/app/services/trading.py @@ -23,6 +23,12 @@ class TradingService: self._client = httpx.AsyncClient(timeout=10.0) self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second) + def _check_auth(self) -> bool: + if not token_manager.is_authenticated: + logger.debug("API 인증되지 않음 - 매매 불가") + return False + return True + def _get_tr_id(self, side: str) -> str: mode = settings.kis.server_mode return _TR_IDS.get(mode, _TR_IDS["vps"]).get(side, "VTTC0802U") @@ -35,6 +41,9 @@ class TradingService: price: int = 0, order_type: str = "00", ) -> dict: + if not self._check_auth(): + return {"rt_cd": "-1", "msg": "API 인증되지 않음", "order_no": ""} + await self._rate_limiter.acquire() tr_id = self._get_tr_id(side) @@ -86,6 +95,9 @@ class TradingService: async def modify_order( self, order_no: str, stock_code: str, qty: int, price: int, order_type: str = "00" ) -> dict: + if not self._check_auth(): + return {"rt_cd": "-1", "msg": "API 인증되지 않음"} + await self._rate_limiter.acquire() tr_id = self._get_tr_id("modify") @@ -123,6 +135,9 @@ class TradingService: return result async def cancel_order(self, order_no: str, stock_code: str, qty: int) -> dict: + if not self._check_auth(): + return {"rt_cd": "-1", "msg": "API 인증되지 않음"} + await self._rate_limiter.acquire() tr_id = self._get_tr_id("cancel") diff --git a/app/templates/index.html b/app/templates/index.html index e293c0b..2806626 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -41,6 +41,9 @@ #ws-status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } #ws-status.connected { background: #3fb950; } #ws-status.disconnected { background: #f85149; } + .alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; display: none; } + .alert.warning { background: #3a2a00; border: 1px solid #d29922; color: #e3b341; } + .alert.warning.show { display: block; } @@ -53,6 +56,11 @@
+
+ API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요. + 현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다. +
+

총 투자금

@@ -136,6 +144,11 @@ try { const res = await fetch(API + '/api/dashboard/'); const data = await res.json(); + + if (!data.authenticated) { + document.getElementById('auth-alert').classList.add('show'); + } + const s = data.summary; document.getElementById('total-invested').textContent = formatPrice(s.total_invested); diff --git a/data/stock.db b/data/stock.db new file mode 100644 index 0000000..e2651fa Binary files /dev/null and b/data/stock.db differ diff --git a/data/stock.db-shm b/data/stock.db-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/data/stock.db-shm differ diff --git a/data/stock.db-wal b/data/stock.db-wal new file mode 100644 index 0000000..e69de29