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 @@
- API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요. + API 키가 설정되지 않았거나 유효하지 않습니다. config.yaml 파일에 KIS 설정을 확인하세요. 현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다.
@@ -200,6 +216,65 @@
+ + +
+
+
+
+ + +
+ +
+
+ + + + +
@@ -336,25 +411,145 @@ function connectWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws/realtime`); - ws.onopen = () => { - document.getElementById('ws-status').className = 'connected'; - document.getElementById('status-text').textContent = '실시간 연결됨'; - }; - ws.onclose = () => { - document.getElementById('ws-status').className = 'disconnected'; - document.getElementById('status-text').textContent = '연결 끊김 - 재연결 중...'; - setTimeout(connectWS, 3000); - }; - ws.onmessage = (e) => { - const data = JSON.parse(e.data); - if (data.type === 'price') loadDashboard(); - }; + ws.onclose = () => setTimeout(connectWS, 3000); + } + + async function loadStatus() { + try { + const res = await fetch(API + '/api/dashboard/status'); + const data = await res.json(); + const el = document.getElementById('ws-status'); + const text = document.getElementById('status-text'); + if (data.realtime_connected) { + el.className = 'connected'; + text.textContent = '실시간 연결됨'; + } else { + el.className = 'disconnected'; + text.textContent = data.authenticated ? '연결 끊김' : '인증 필요'; + } + } catch (e) { console.error('상태 조회 실패:', e); } + } + + async function loadStockInfo() { + const code = document.getElementById('stock-search-code').value.trim(); + if (!code) return; + const content = document.getElementById('stock-info-content'); + const empty = document.getElementById('stock-info-empty'); + content.style.display = 'none'; + empty.style.display = 'none'; + try { + const [priceRes, obRes] = await Promise.all([ + fetch(API + `/api/stocks/${code}/price`), + fetch(API + `/api/stocks/${code}/orderbook`), + ]); + if (!priceRes.ok) { empty.style.display = 'block'; empty.textContent = '시세 조회 실패'; return; } + const p = await priceRes.json(); + document.getElementById('si-name').textContent = p.stock_name || code; + document.getElementById('si-code').textContent = code; + const priceEl = document.getElementById('si-price'); + priceEl.textContent = formatPrice(p.current_price); + priceEl.className = 'stock-price-main ' + (p.change_price >= 0 ? 'positive' : 'negative'); + const chEl = document.getElementById('si-change'); + const sign = p.change_price >= 0 ? '+' : ''; + chEl.textContent = `${sign}${p.change_price.toLocaleString()} (${sign}${p.change_rate.toFixed(2)}%)`; + chEl.className = 'stock-change ' + (p.change_price >= 0 ? 'positive' : 'negative'); + document.getElementById('si-open').textContent = formatPrice(p.open_price); + document.getElementById('si-high').textContent = formatPrice(p.high_price); + document.getElementById('si-low').textContent = formatPrice(p.low_price); + document.getElementById('si-volume').textContent = p.volume ? p.volume.toLocaleString() : '-'; + document.getElementById('si-amount').textContent = p.trade_amount ? formatPrice(p.trade_amount) : '-'; + if (obRes.ok) { + const ob = await obRes.json(); + const askTbody = document.getElementById('ob-asks'); + askTbody.innerHTML = ob.ask_prices.slice().reverse().map((price, i) => { + const vol = ob.ask_volumes[4 - i] || 0; + return `${5 - i}${price ? price.toLocaleString() : '-'}${vol.toLocaleString()}`; + }).join(''); + document.getElementById('ob-mid').textContent = p.current_price ? p.current_price.toLocaleString() : '-'; + const bidTbody = document.getElementById('ob-bids'); + bidTbody.innerHTML = ob.bid_prices.map((price, i) => { + const vol = ob.bid_volumes[i] || 0; + return `${i + 1}${price ? price.toLocaleString() : '-'}${vol.toLocaleString()}`; + }).join(''); + } + content.style.display = 'block'; + loadChart(30); + } catch (e) { + console.error('종목 조회 실패:', e); + empty.style.display = 'block'; + empty.textContent = '조회 중 오류가 발생했습니다.'; + } + } + + async function loadChart(days) { + const code = document.getElementById('stock-search-code').value.trim(); + if (!code) return; + try { + const res = await fetch(API + `/api/stocks/${code}/chart?days=${days}`); + const data = await res.json(); + if (!data.length) return; + drawChart(data); + } catch (e) { console.error('차트 조회 실패:', e); } + } + + function drawChart(data) { + const canvas = document.getElementById('chart-canvas'); + const ctx = canvas.getContext('2d'); + const rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = 300; + const W = canvas.width, H = canvas.height; + const pad = { top: 20, right: 60, bottom: 30, left: 10 }; + const chartW = W - pad.left - pad.right; + const chartH = H - pad.top - pad.bottom; + ctx.clearRect(0, 0, W, H); + const prices = data.flatMap(d => [d.high, d.low]); + const minP = Math.min(...prices); + const maxP = Math.max(...prices); + const range = maxP - minP || 1; + const barW = Math.max(1, (chartW / data.length) - 1); + const gap = chartW / data.length; + const toY = (p) => pad.top + chartH - ((p - minP) / range) * chartH; + ctx.strokeStyle = '#30363d'; + ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const y = pad.top + (chartH / 4) * i; + ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke(); + const val = maxP - (range / 4) * i; + ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left'; + ctx.fillText(val.toLocaleString(), W - pad.right + 4, y + 4); + } + data.forEach((d, i) => { + const x = pad.left + gap * i + gap / 2; + const isUp = d.close >= d.open; + ctx.strokeStyle = isUp ? '#3fb950' : '#f85149'; + ctx.fillStyle = isUp ? '#3fb950' : '#f85149'; + ctx.beginPath(); ctx.moveTo(x, toY(d.high)); ctx.lineTo(x, toY(d.low)); ctx.stroke(); + const oY = toY(d.open), cY = toY(d.close); + const bodyTop = Math.min(oY, cY); + const bodyH = Math.max(Math.abs(oY - cY), 1); + ctx.fillRect(x - barW / 2, bodyTop, barW, bodyH); + }); + if (data.length > 0) { + const step = Math.max(1, Math.floor(data.length / 6)); + ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; + for (let i = 0; i < data.length; i += step) { + const x = pad.left + gap * i + gap / 2; + const label = data[i].date; + ctx.fillText(label.slice(4, 6) + '/' + label.slice(6, 8), x, H - 8); + } + } } loadDashboard(); + loadStatus(); setInterval(loadDashboard, 10000); + setInterval(loadStatus, 5000); connectWS(); initHistoryDates(); + document.getElementById('stock-search-code').addEventListener('keydown', (e) => { + if (e.key === 'Enter') loadStockInfo(); + }); diff --git a/config.yaml b/config.yaml index 676b141..7f8b54e 100644 --- a/config.yaml +++ b/config.yaml @@ -1,11 +1,21 @@ app: name: "StockAutomation" version: "0.1.0" + host: "0.0.0.0" + port: 8000 + debug: true + db_path: "./data/stock.db" + log_level: "INFO" kis: + app_key: "PS1Hj6x9hy7Rw1bL1nPn3X2zw4VExJsGp7hJ" + app_secret: "IGeQPUmgQZvdQgH3pHUD6dOIefRpWtRD6TSzQhwx5grj7pPyvOeUK0mxG+g4jsP6e89UJQnisWE6yLelLnOrhemftWoJS/ld1+xyYbMgHy230SzKU2cZbje89WNlVYoXn25NWbrjMTsi7Y1WdX3eZ0IswH/VQPtYYxj7ZkwrjBXJAvvgXV0=" + account_no: "50198112" + account_code: "01" + hts_id: "moneman" server_mode: "vps" # vps: 모의투자, real: 실전투자 rate_limit: - requests_per_second: 1.0 # 모의투자: 1.0, 실전투자: 2.0 + requests_per_second: 2.0 # 모의투자/실전투자 공통: 초당 2회 retry_delay: 1.5 collector: