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

8
.gitignore vendored
View File

@@ -1,4 +1,6 @@
./.venv .venv
./app/__pycache__/ __pycache__/
*.pyc *.pyc
.auth_cache.json
config.yaml
.env

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib import json
import hmac
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
import httpx import httpx
@@ -17,6 +16,7 @@ logger = setup_logger("auth")
_TOKEN_URL = "/oauth2/tokenP" _TOKEN_URL = "/oauth2/tokenP"
_APPROVAL_URL = "/oauth2/Approval" _APPROVAL_URL = "/oauth2/Approval"
_HASHKEY_URL = "/uapi/hashkey" _HASHKEY_URL = "/uapi/hashkey"
_TOKEN_CACHE_PATH = Path(settings.app.db_path).parent / ".auth_cache.json"
class TokenManager: class TokenManager:
@@ -27,8 +27,41 @@ class TokenManager:
self._approval_expires_at: datetime = datetime.min self._approval_expires_at: datetime = datetime.min
self._rate_limiter = RateLimiter(requests_per_second=1.0) self._rate_limiter = RateLimiter(requests_per_second=1.0)
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._client = httpx.AsyncClient(timeout=10.0) self._client = httpx.AsyncClient(timeout=15.0)
self._authenticated: bool = False 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 @property
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
@@ -49,18 +82,30 @@ class TokenManager:
"appkey": settings.kis.app_key, "appkey": settings.kis.app_key,
"appsecret": settings.kis.app_secret, "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: for attempt in range(3):
logger.warning("토큰 발급 실패: %s", data.get("message", resp.status_code)) 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 return
self._access_token = data["access_token"] logger.error("토큰 발급 3회 모두 실패 (1분 제한)")
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: async def _request_approval_key(self) -> None:
if not settings.kis.app_key or not settings.kis.app_secret: if not settings.kis.app_key or not settings.kis.app_secret:
@@ -72,16 +117,30 @@ class TokenManager:
"appkey": settings.kis.app_key, "appkey": settings.kis.app_key,
"secretkey": settings.kis.app_secret, "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: for attempt in range(3):
logger.warning("approval_key 발급 실패: %s", data.get("message", resp.status_code)) 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 return
self._approval_key = data["approval_key"] logger.error("approval_key 발급 3회 모두 실패 (1분 제한)")
self._approval_expires_at = datetime.now() + timedelta(hours=23, minutes=50)
logger.info("WebSocket approval_key 발급 완료 (만료: %s)", self._approval_expires_at)
async def get_access_token(self) -> str: async def get_access_token(self) -> str:
async with self._lock: async with self._lock:

View File

@@ -1,33 +1,28 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import yaml import yaml
from pydantic import Field
from pydantic_settings import BaseSettings
class KISConfig(BaseSettings): class KISConfig:
app_key: str = Field(default="", alias="KIS_APP_KEY") app_key: str = ""
app_secret: str = Field(default="", alias="KIS_APP_SECRET") app_secret: str = ""
account_no: str = Field(default="", alias="KIS_ACCOUNT_NO") account_no: str = ""
account_code: str = Field(default="01", alias="KIS_ACCOUNT_CODE") account_code: str = "01"
hts_id: str = Field(default="", alias="KIS_HTS_ID") hts_id: str = ""
server_mode: str = Field(default="vps", alias="KIS_SERVER_MODE") server_mode: str = "vps"
model_config = {"env_file": ".env", "extra": "ignore"}
class AppConfig(BaseSettings): class AppConfig:
host: str = Field(default="0.0.0.0", alias="APP_HOST") name: str = "StockAutomation"
port: int = Field(default=8000, alias="APP_PORT") version: str = "0.1.0"
debug: bool = Field(default=False, alias="APP_DEBUG") host: str = "0.0.0.0"
db_path: str = Field(default="./data/stock.db", alias="DB_PATH") port: int = 8000
log_level: str = Field(default="INFO", alias="LOG_LEVEL") debug: bool = False
db_path: str = "./data/stock.db"
model_config = {"env_file": ".env", "extra": "ignore"} log_level: str = "INFO"
class CollectorConfig: class CollectorConfig:
@@ -72,9 +67,18 @@ class Settings:
with open(yaml_path, encoding="utf-8") as f: with open(yaml_path, encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {} 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", {}) kis = data.get("kis", {})
if "server_mode" in kis: for key, val in kis.items():
self.kis.server_mode = kis["server_mode"] if key == "rate_limit":
continue
if hasattr(self.kis, key):
setattr(self.kis, key, val)
rl = kis.get("rate_limit", {}) rl = kis.get("rate_limit", {})
if "requests_per_second" in rl: if "requests_per_second" in rl:
self.rate_limit.requests_per_second = rl["requests_per_second"] self.rate_limit.requests_per_second = rl["requests_per_second"]

View File

@@ -44,6 +44,11 @@ async def lifespan(app: FastAPI):
if token_manager.is_authenticated: if token_manager.is_authenticated:
start_scheduler() start_scheduler()
try:
await realtime_service.connect()
logger.info("KIS WebSocket 연결 완료")
except Exception as e:
logger.warning("KIS WebSocket 연결 실패: %s", e)
else: else:
logger.info("인증 실패 - 스케줄러 미시작 (인증 후 수동 시작 가능)") logger.info("인증 실패 - 스케줄러 미시작 (인증 후 수동 시작 가능)")

View File

@@ -5,22 +5,59 @@ from sqlalchemy.orm import Session
from app.core.auth import token_manager from app.core.auth import token_manager
from app.core.database import get_db 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.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 = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/") @router.get("/")
def dashboard(db: Session = Depends(get_db)) -> dict: async def dashboard(db: Session = Depends(get_db)) -> dict:
holdings = db.query(Holding).all()
recent_trades = db.query(Trade).order_by(Trade.created_at.desc()).limit(20).all() 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_strategies = db.query(Strategy).filter(Strategy.is_active == True).all()
active_stocks = db.query(Stock).filter(Stock.is_active == True).all() active_stocks = db.query(Stock).filter(Stock.is_active == True).all()
total_profit = sum(h.profit for h in holdings) holdings = []
total_invested = sum(h.avg_price * h.qty for h in holdings if h.avg_price > 0) total_profit = 0.0
total_evaluated = sum(h.current_price * h.qty for h in holdings if h.current_price > 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 = [] jobs = []
if scheduler.running: if scheduler.running:
@@ -42,18 +79,7 @@ def dashboard(db: Session = Depends(get_db)) -> dict:
"active_strategies": len(active_strategies), "active_strategies": len(active_strategies),
"active_stocks": len(active_stocks), "active_stocks": len(active_stocks),
}, },
"holdings": [ "holdings": 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
],
"recent_trades": [ "recent_trades": [
{ {
"id": t.id, "id": t.id,
@@ -68,3 +94,11 @@ def dashboard(db: Session = Depends(get_db)) -> dict:
], ],
"scheduler_jobs": jobs, "scheduler_jobs": jobs,
} }
@router.get("/status")
def get_status() -> dict:
return {
"authenticated": token_manager.is_authenticated,
"realtime_connected": realtime_service.is_connected,
}

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session 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") @router.get("/{stock_code}/price")
async def get_price(stock_code: str) -> dict: 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: if not price:
raise HTTPException(status_code=404, detail="시세 조회 실패") raise HTTPException(status_code=404, detail="시세 조회 실패")
return price 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") end = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=days)).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 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

View File

@@ -19,7 +19,7 @@ _FID_COND_MRKT_DIV_CODE = "FID_COND_MRKT_DIV_CODE"
class MarketDataService: class MarketDataService:
def __init__(self) -> None: 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) self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
def _check_auth(self) -> bool: def _check_auth(self) -> bool:

View File

@@ -30,6 +30,10 @@ class RealtimeService:
self._heartbeat_task: asyncio.Task | None = None self._heartbeat_task: asyncio.Task | None = None
self._receive_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: def on(self, event: str, callback: Callable) -> None:
self._callbacks.setdefault(event, []).append(callback) self._callbacks.setdefault(event, []).append(callback)
@@ -40,7 +44,7 @@ class RealtimeService:
try: try:
self._ws = await websockets.connect( self._ws = await websockets.connect(
url, url,
extra_headers={"approval_key": approval_key, "type": "Y"}, additional_headers={"approval_key": approval_key, "type": "Y"},
) )
self._running = True self._running = True
self._receive_task = asyncio.create_task(self._receive_loop()) self._receive_task = asyncio.create_task(self._receive_loop())
@@ -106,11 +110,14 @@ class RealtimeService:
await self._handle_message(raw_msg) await self._handle_message(raw_msg)
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
logger.warning("WebSocket 연결 끊김. 5초 후 재연결...") logger.warning("WebSocket 연결 끊김. 5초 후 재연결...")
if self._running:
await asyncio.sleep(5)
await self.connect()
except Exception as e: except Exception as e:
logger.error("WebSocket 수신 오류: %s", 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: async def _handle_message(self, raw_msg: str) -> None:
try: try:

View File

@@ -60,6 +60,19 @@
.filter-row { display: flex; gap: 12px; align-items: end; margin-bottom: 16px; flex-wrap: wrap; } .filter-row { display: flex; gap: 12px; align-items: end; margin-bottom: 16px; flex-wrap: wrap; }
.positive { color: #3fb950; } .positive { color: #3fb950; }
.negative { color: #f85149; } .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; }
</style> </style>
</head> </head>
<body> <body>
@@ -74,6 +87,9 @@
<a href="#" data-page="history"> <a href="#" data-page="history">
<span class="icon">&#128203;</span> 과거 기록 조회 <span class="icon">&#128203;</span> 과거 기록 조회
</a> </a>
<a href="#" data-page="stock-info">
<span class="icon">&#128200;</span> 종목 조회
</a>
</nav> </nav>
<div class="sidebar-footer">v0.1.0</div> <div class="sidebar-footer">v0.1.0</div>
</aside> </aside>
@@ -91,7 +107,7 @@
<!-- 실거래 페이지 --> <!-- 실거래 페이지 -->
<div id="page-trading" class="page active"> <div id="page-trading" class="page active">
<div id="auth-alert" class="alert warning"> <div id="auth-alert" class="alert warning">
API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요. API 키가 설정되지 않았거나 유효하지 않습니다. config.yaml 파일에 KIS 설정을 확인하세요.
현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다. 현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다.
</div> </div>
@@ -200,6 +216,65 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 종목 조회 페이지 -->
<div id="page-stock-info" class="page">
<div class="section">
<div class="filter-row">
<div class="form-group">
<label>종목코드</label>
<input id="stock-search-code" type="text" placeholder="005930" />
</div>
<button class="btn primary" onclick="loadStockInfo()">조회</button>
</div>
</div>
<div id="stock-info-content" style="display:none;">
<div class="section">
<div class="stock-header">
<span class="name" id="si-name">-</span>
<span class="code" id="si-code">-</span>
</div>
<div class="stock-price-main" id="si-price">-</div>
<div class="stock-change" id="si-change">-</div>
<div class="info-grid">
<div>
<table>
<tr><th style="width:100px;">시가</th><td id="si-open">-</td></tr>
<tr><th>고가</th><td id="si-high">-</td></tr>
<tr><th>저가</th><td id="si-low">-</td></tr>
<tr><th>거래량</th><td id="si-volume">-</td></tr>
<tr><th>거래대금</th><td id="si-amount">-</td></tr>
</table>
</div>
<div>
<table class="orderbook-table">
<thead><tr><th>#</th><th>매도</th><th>수량</th></tr></thead>
<tbody id="ob-asks"></tbody>
<tr class="mid-row"><td></td><td id="ob-mid">-</td><td></td></tr>
<thead><tr><th>#</th><th>매수</th><th>수량</th></tr></thead>
<tbody id="ob-bids"></tbody>
</table>
</div>
</div>
</div>
<div class="section">
<h2>일봉 차트</h2>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button class="btn" onclick="loadChart(30)">30일</button>
<button class="btn" onclick="loadChart(60)">60일</button>
<button class="btn" onclick="loadChart(120)">120일</button>
</div>
<canvas id="chart-canvas" class="chart-canvas"></canvas>
</div>
</div>
<div id="stock-info-empty" style="display:none; text-align:center; padding:40px; color:#8b949e;">
종목코드를 입력하고 조회 버튼을 누르세요.
</div>
</div>
</div> </div>
</div> </div>
@@ -336,25 +411,145 @@
function connectWS() { function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`); ws = new WebSocket(`${proto}://${location.host}/ws/realtime`);
ws.onopen = () => { ws.onclose = () => setTimeout(connectWS, 3000);
document.getElementById('ws-status').className = 'connected'; }
document.getElementById('status-text').textContent = '실시간 연결됨';
}; async function loadStatus() {
ws.onclose = () => { try {
document.getElementById('ws-status').className = 'disconnected'; const res = await fetch(API + '/api/dashboard/status');
document.getElementById('status-text').textContent = '연결 끊김 - 재연결 중...'; const data = await res.json();
setTimeout(connectWS, 3000); const el = document.getElementById('ws-status');
}; const text = document.getElementById('status-text');
ws.onmessage = (e) => { if (data.realtime_connected) {
const data = JSON.parse(e.data); el.className = 'connected';
if (data.type === 'price') loadDashboard(); 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 `<tr class="ask-row"><td>${5 - i}</td><td>${price ? price.toLocaleString() : '-'}</td><td>${vol.toLocaleString()}</td></tr>`;
}).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 `<tr class="bid-row"><td>${i + 1}</td><td>${price ? price.toLocaleString() : '-'}</td><td>${vol.toLocaleString()}</td></tr>`;
}).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(); loadDashboard();
loadStatus();
setInterval(loadDashboard, 10000); setInterval(loadDashboard, 10000);
setInterval(loadStatus, 5000);
connectWS(); connectWS();
initHistoryDates(); initHistoryDates();
document.getElementById('stock-search-code').addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadStockInfo();
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,11 +1,21 @@
app: app:
name: "StockAutomation" name: "StockAutomation"
version: "0.1.0" version: "0.1.0"
host: "0.0.0.0"
port: 8000
debug: true
db_path: "./data/stock.db"
log_level: "INFO"
kis: 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: 실전투자 server_mode: "vps" # vps: 모의투자, real: 실전투자
rate_limit: rate_limit:
requests_per_second: 1.0 # 모의투자: 1.0, 실전투자: 2.0 requests_per_second: 2.0 # 모의투자/실전투자 공통: 초당 2회
retry_delay: 1.5 retry_delay: 1.5
collector: collector: