initial commit

This commit is contained in:
2026-02-04 00:16:34 +09:00
commit ae11528dd9
867 changed files with 209640 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Project specific
settings.yaml
kis_stock.db
kis_stock.db-shm
kis_stock.db-wal
kis_token.tmp
*.log
# KIS Sample
한국투자증권/kis_devlp.yaml

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Python 3.11 Full Image (Fix for Segmentation Fault in slim)
FROM python:3.11
# Set working directory
WORKDIR /app
# Upgrade pip
RUN pip install --upgrade pip setuptools wheel
# Install core dependencies first
RUN pip install fastapi uvicorn sqlalchemy requests pandas pyyaml jinja2 python-multipart websockets python-telegram-bot watchfiles
# Install heavy dependencies separately (to debug Segfault)
RUN pip install google-generativeai
# Copy application code
COPY backend /app/backend
COPY frontend /app/frontend
COPY settings.yaml /app/settings.yaml
# Set Env Defaults
ENV PYTHONUNBUFFERED=1
# Expose Port 80
EXPOSE 80
# Run Application (Port 80)
WORKDIR /app/backend
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]

18
backend/config.py Normal file
View File

@@ -0,0 +1,18 @@
import os
import yaml
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "settings.yaml")
def load_config():
if not os.path.exists(CONFIG_FILE):
return {}
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def save_config(config_data):
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
yaml.dump(config_data, f, allow_unicode=True)
def get_kis_config():
config = load_config()
return config.get('kis', {})

5285
backend/data/nasdaq.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

112
backend/database.py Normal file
View File

@@ -0,0 +1,112 @@
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import datetime
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB_URL = f"sqlite:///{os.path.join(BASE_DIR, 'kis_stock.db')}"
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
# Enable WAL mode for better concurrency
from sqlalchemy import event
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Stock(Base):
__tablename__ = "stocks"
code = Column(String, primary_key=True, index=True)
name = Column(String, index=True)
name_eng = Column(String, nullable=True) # English Name
market = Column(String) # KOSPI, KOSDAQ, NASD, NYSE, AMEX
sector = Column(String, nullable=True)
industry = Column(String, nullable=True) # Detailed Industry
type = Column(String, default="DOMESTIC") # DOMESTIC, OVERSEAS
financial_status = Column(String, nullable=True) # 'N', 'D', 'E' etc from Nasdaq
is_etf = Column(Boolean, default=False)
current_price = Column(Float, default=0.0)
class Watchlist(Base):
__tablename__ = "watchlist"
id = Column(Integer, primary_key=True, index=True)
code = Column(String, index=True)
name = Column(String) # Cache name for display
market = Column(String)
is_monitoring = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.datetime.now)
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(String, nullable=True) # KIS Order ID
code = Column(String, index=True)
type = Column(String) # BUY, SELL
price = Column(Float)
quantity = Column(Integer)
status = Column(String) # PENDING, FILLED, CANCELLED
created_at = Column(DateTime, default=datetime.datetime.now)
class TradeSetting(Base):
__tablename__ = "trade_settings"
code = Column(String, primary_key=True)
target_price = Column(Float, nullable=True)
stop_loss_price = Column(Float, nullable=True)
trailing_stop_percent = Column(Float, nullable=True)
is_active = Column(Boolean, default=False)
class News(Base):
__tablename__ = "news"
id = Column(Integer, primary_key=True, index=True)
title = Column(String)
link = Column(String, unique=True)
pub_date = Column(String)
analysis_result = Column(Text)
impact_score = Column(Integer)
related_sector = Column(String)
created_at = Column(DateTime, default=datetime.datetime.now)
class StockPrice(Base):
__tablename__ = "stock_prices"
id = Column(Integer, primary_key=True, index=True)
code = Column(String, index=True)
price = Column(Float)
change = Column(Float)
volume = Column(Integer)
created_at = Column(DateTime, default=datetime.datetime.now)
class AccountBalance(Base):
__tablename__ = "account_balance"
id = Column(Integer, primary_key=True)
total_eval = Column(Float, default=0.0) # 총평가금액
deposit = Column(Float, default=0.0) # 예수금
total_profit = Column(Float, default=0.0) # 평가손익
updated_at = Column(DateTime, default=datetime.datetime.now)
class Holding(Base):
__tablename__ = "holdings"
id = Column(Integer, primary_key=True, index=True)
code = Column(String, index=True)
name = Column(String)
quantity = Column(Integer)
price = Column(Float) # 매입평단가
current_price = Column(Float) # 현재가
profit_rate = Column(Float)
market = Column(String) # DOMESTIC, NASD, etc.
updated_at = Column(DateTime, default=datetime.datetime.now)
def init_db():
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

379
backend/kis_api.py Normal file
View File

@@ -0,0 +1,379 @@
import requests
import json
import datetime
import os
import time
import copy
from config import get_kis_config
import logging
# Basic Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("KIS_API")
class KisApi:
def __init__(self):
self.config = get_kis_config()
self.app_key = self.config.get("app_key")
self.app_secret = self.config.get("app_secret")
self.account_no = str(self.config.get("account_no", "")).replace("-", "").strip() # 8 digits
self.account_prod = str(self.config.get("account_prod", "01")).strip() # 2 digits
self.is_paper = self.config.get("is_paper", True)
self.htsid = self.config.get("htsid", "")
logger.info(f"Initialized KIS API: Account={self.account_no}, Prod={self.account_prod}, Paper={self.is_paper}")
if self.is_paper:
self.base_url = "https://openapivts.koreainvestment.com:29443"
else:
self.base_url = "https://openapi.koreainvestment.com:9443"
self.token_file = "kis_token.tmp"
self.access_token = None
self.token_expired = None
self.last_req_time = 0
self._auth()
def _auth(self):
# Clean outdated token file if needed or load existing
if os.path.exists(self.token_file):
with open(self.token_file, 'r') as f:
data = json.load(f)
expired = datetime.datetime.strptime(data['expired'], "%Y-%m-%d %H:%M:%S")
if expired > datetime.datetime.now():
self.access_token = data['token']
self.token_expired = expired
logger.info("Loaded credentials from cache.")
return
# Request new token
url = f"{self.base_url}/oauth2/tokenP"
headers = {"content-type": "application/json"}
body = {
"grant_type": "client_credentials",
"appkey": self.app_key,
"appsecret": self.app_secret
}
res = requests.post(url, headers=headers, data=json.dumps(body))
if res.status_code == 200:
data = res.json()
self.access_token = data['access_token']
self.token_expired = datetime.datetime.strptime(data['access_token_token_expired'], "%Y-%m-%d %H:%M:%S")
# Save to file
with open(self.token_file, 'w') as f:
json.dump({
"token": self.access_token,
"expired": data['access_token_token_expired']
}, f)
logger.info("Issued new access token.")
else:
logger.error(f"Auth Failed: {res.text}")
raise Exception("Authentication Failed")
def get_websocket_key(self):
"""
Get Approval Key for WebSocket
"""
url = f"{self.base_url}/oauth2/Approval"
headers = {"content-type": "application/json"}
body = {
"grant_type": "client_credentials",
"appkey": self.app_key,
"secretkey": self.app_secret
}
res = requests.post(url, headers=headers, data=json.dumps(body))
if res.status_code == 200:
return res.json().get("approval_key")
else:
logger.error(f"WS Key Failed: {res.text}")
return None
def _get_header(self, tr_id=None):
header = {
"Content-Type": "application/json",
"authorization": f"Bearer {self.access_token}",
"appkey": self.app_key,
"appsecret": self.app_secret,
"tr_id": tr_id,
"custtype": "P"
}
return header
def _request(self, method, path, tr_id=None, x_tr_id_buy=None, x_tr_id_sell=None, **kwargs):
"""
Centralized request handler with auto-token refresh logic and 500ms throttling.
"""
# Throttling
now = time.time()
diff = now - self.last_req_time
if diff < 0.5:
time.sleep(0.5 - diff)
self.last_req_time = time.time()
url = f"{self.base_url}{path}"
# Determine TR ID
curr_tr_id = tr_id
if x_tr_id_buy and x_tr_id_sell:
pass
# Prepare headers
headers = self._get_header(curr_tr_id)
# Execute Request
try:
if method.upper() == "GET":
res = requests.get(url, headers=headers, **kwargs)
else:
res = requests.post(url, headers=headers, **kwargs)
# Check for Token Expiration (EGW00123)
if res.status_code == 200:
data = res.json()
if isinstance(data, dict):
msg_cd = data.get('msg_cd', '')
if msg_cd == 'EGW00123': # Expired Token
logger.warning("Token expired (EGW00123). Refreshing token and retrying...")
# Remove token file
if os.path.exists(self.token_file):
os.remove(self.token_file)
# Re-auth
self._auth()
# Update headers with new token
headers = self._get_header(curr_tr_id)
# Retry
if method.upper() == "GET":
res = requests.get(url, headers=headers, **kwargs)
else:
res = requests.post(url, headers=headers, **kwargs)
return res.json()
return data
else:
logger.error(f"API Request Failed [{res.status_code}]: {res.text}")
return None
except Exception as e:
logger.error(f"Request Exception: {e}")
return None
def get_current_price(self, code):
"""
inquire-price
"""
tr_id = "FHKST01010100"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code
}
res = self._request("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", tr_id=tr_id, params=params)
return res.get('output', {}) if res else None
def get_balance(self, source=None):
"""
inquire-balance
Cached for 2 seconds to prevent rate limit (EGW00201)
"""
# Check cache
now = time.time()
if hasattr(self, '_balance_cache') and self._balance_cache:
last_time, data = self._balance_cache
if now - last_time < 2.0: # 2 Seconds TTL
return data
log_source = f" [Source: {source}]" if source else ""
logger.info(f"get_balance{log_source}: Account={self.account_no}, Paper={self.is_paper}")
tr_id = "VTTC8434R" if self.is_paper else "TTTC8434R"
params = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_prod,
"AFHR_FLPR_YN": "N",
"OFL_YN": "",
"INQR_DVSN": "02", # 02: By Stock
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "00",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": ""
}
res = self._request("GET", "/uapi/domestic-stock/v1/trading/inquire-balance", tr_id=tr_id, params=params)
# Update Cache if success
if res and res.get('rt_cd') == '0':
self._balance_cache = (now, res)
return res
def get_overseas_balance(self, exchange="NASD"):
"""
overseas-stock inquire-balance
"""
# For overseas, we need to know the exchange code often, but inquire-balance might return all if configured?
# Typically requires an exchange code in some params or TR IDs specific to exchange?
# Looking at docs: TTTS3012R is for "Overseas Stock Balance"
tr_id = "VTTS3012R" if self.is_paper else "TTTS3012R"
# Paper env TR ID is tricky, usually V... but let's assume VTTS3012R or JTTT3012R?
# Common pattern: Real 'T...' -> Paper 'V...'
params = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_prod,
"OVRS_EXCG_CD": exchange, # NASD, NYSE, AMEX, HKS, TSE, etc.
"TR_CRCY_CD": "USD", # Transaction Currency
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": ""
}
return self._request("GET", "/uapi/overseas-stock/v1/trading/inquire-balance", tr_id=tr_id, params=params)
def place_order(self, code, type, qty, price):
"""
order-cash
type: 'buy' or 'sell'
"""
if self.is_paper:
tr_id = "VTTC0012U" if type == 'buy' else "VTTC0011U"
else:
tr_id = "TTTC0012U" if type == 'buy' else "TTTC0011U"
# 00: Limit (Specified Price), 01: Market Price
ord_dvsn = "00" if int(price) > 0 else "01"
body = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_prod,
"PDNO": code,
"ORD_DVSN": ord_dvsn,
"ORD_QTY": str(qty),
"ORD_UNPR": str(price),
"EXCG_ID_DVSN_CD": "KRX", # KRX for Exchange
"SLL_TYPE": "01", # 01: Normal Sell
"CNDT_PRIC": ""
}
return self._request("POST", "/uapi/domestic-stock/v1/trading/order-cash", tr_id=tr_id, data=json.dumps(body))
def place_overseas_order(self, code, type, qty, price, market="NASD"):
"""
overseas-stock order
"""
# Checks for Paper vs Real and Buy vs Sell
# TR_ID might vary by country. Assuming US (NASD, NYSE, AMEX).
if self.is_paper:
tr_id = "VTTT1002U" if type == 'buy' else "VTTT1006U"
else:
# US Real: JTTT1002U (Buy), JTTT1006U (Sell)
# Note: This TR ID is for US Night Market (Main).
tr_id = "JTTT1002U" if type == 'buy' else "JTTT1006U"
# Price '0' or empty is usually Market Price, but overseas api often requires specific handling.
# US Market Price Order usually uses ord_dvsn="00" and price="0" or empty?
# KIS Docs: US Limit="00", Market="00"?? No, usually "00" is Limit.
# "32": LOO, "33": LOC, "34": MOO, "35": MOC...
# Let's stick to Limit ("00") for now. If price is 0, user might mean Market, but US API requires price for limit.
# If user sends 0, let's try to assume "00" (Limit) with price 0 (might fail) or valid Market ("01"? No).
# Safe bet: US Market Order is "00" (Limit) with Price 0? No.
# Use "00" (Limit) as default. If price is 0, we can't easily do Market on US via standard "01" like domestic.
ord_dvsn = "00" # Limit
body = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_prod,
"OVRS_EXCG_CD": market,
"PDNO": code,
"ORD_QTY": str(qty),
"OVRS_ORD_UNPR": str(price),
"ORD_SVR_DVSN_CD": "0",
"ORD_DVSN": ord_dvsn
}
return self._request("POST", "/uapi/overseas-stock/v1/trading/order", tr_id=tr_id, data=json.dumps(body))
def get_daily_orders(self, start_date=None, end_date=None, expanded_code=None):
"""
inquire-daily-ccld
"""
if self.is_paper:
tr_id = "VTTC0081R" # 3-month inner
else:
tr_id = "TTTC0081R" # 3-month inner
if not start_date:
start_date = datetime.datetime.now().strftime("%Y%m%d")
if not end_date:
end_date = datetime.datetime.now().strftime("%Y%m%d")
params = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_prod,
"INQR_STRT_DT": start_date,
"INQR_END_DT": end_date,
"SLL_BUY_DVSN_CD": "00", # All
"PDNO": "",
"CCLD_DVSN": "00", # All (Executed + Unexecuted)
"INQR_DVSN": "00", # Reverse Order
"INQR_DVSN_3": "00", # All
"ORD_GNO_BRNO": "",
"ODNO": "",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": ""
}
return self._request("GET", "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", tr_id=tr_id, params=params)
def get_cancelable_orders(self):
"""
inquire-psbl-rvsecncl
"""
tr_id = "VTTC0084R" if self.is_paper else "TTTC0084R"
params = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_prod,
"INQR_DVSN_1": "0", # 0: Order No order
"INQR_DVSN_2": "0", # 0: All
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": ""
}
return self._request("GET", "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl", tr_id=tr_id, params=params)
def cancel_order(self, org_no, order_no, qty, is_buy, price="0", total=True):
"""
order-rvsecncl
"""
if self.is_paper:
tr_id = "VTTC0013U"
else:
tr_id = "TTTC0013U"
rvse_cncl_dvsn_cd = "02"
qty_all = "Y" if total else "N"
body = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_prod,
"KRX_FWDG_ORD_ORGNO": org_no,
"ORGN_ODNO": order_no,
"ORD_DVSN": "00",
"RVSE_CNCL_DVSN_CD": rvse_cncl_dvsn_cd,
"ORD_QTY": str(qty),
"ORD_UNPR": str(price),
"QTY_ALL_ORD_YN": qty_all,
"EXCG_ID_DVSN_CD": "KRX"
}
return self._request("POST", "/uapi/domestic-stock/v1/trading/order-rvsecncl", tr_id=tr_id, data=json.dumps(body))
# Singleton Instance
kis = KisApi()

313
backend/main.py Normal file
View File

@@ -0,0 +1,313 @@
import os
import uvicorn
import threading
import asyncio
from fastapi import FastAPI, HTTPException, Depends, Body, Header, BackgroundTasks
import fastapi
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy.orm import Session
from typing import List, Optional
from database import init_db, get_db, TradeSetting, Order, News, Stock, Watchlist
from config import load_config, save_config, get_kis_config
from kis_api import kis
from trader import trader
from news_ai import news_bot
from telegram_notifier import notifier
import logging
logger = logging.getLogger("MAIN")
app = FastAPI()
# Get absolute path to the directory where main.py is located
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Root project directory (one level up from backend)
PROJECT_ROOT = os.path.dirname(BASE_DIR)
# Frontend directory
FRONTEND_DIR = os.path.join(PROJECT_ROOT, "frontend")
# Create frontend directory if it doesn't exist (for safety)
if not os.path.exists(FRONTEND_DIR):
os.makedirs(FRONTEND_DIR)
# Mount static files
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
from websocket_manager import ws_manager
from fastapi import WebSocket
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await ws_manager.connect_frontend(websocket)
try:
while True:
# Keep alive or handle frontend formatting
data = await websocket.receive_text()
# If frontend sends "subscribe:CODE", we could forward to KIS
if data.startswith("sub:"):
code = data.split(":")[1]
await ws_manager.subscribe_stock(code)
except:
ws_manager.disconnect_frontend(websocket)
@app.on_event("startup")
def startup_event():
init_db()
# Start Background Threads
trader.start()
news_bot.start()
# Start KIS WebSocket Loop
# We need a way to run async loop in bg.
# Uvicorn runs in asyncio loop. We can create task.
asyncio.create_task(ws_manager.start_kis_socket())
notifier.send_message("🚀 KisStock AI 시스템이 시작되었습니다.")
@app.on_event("shutdown")
def shutdown_event():
notifier.send_message("🛑 KisStock AI 시스템이 종료됩니다.")
trader.stop()
news_bot.stop()
# --- Pages ---
@app.get("/")
async def read_index():
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
@app.get("/news")
async def read_news():
return FileResponse(os.path.join(FRONTEND_DIR, "news.html"))
@app.get("/stocks")
async def read_stocks():
return FileResponse(os.path.join(FRONTEND_DIR, "stocks.html"))
@app.get("/settings")
async def read_settings():
return FileResponse(os.path.join(FRONTEND_DIR, "settings.html"))
@app.get("/trade")
async def read_trade():
return FileResponse(os.path.join(FRONTEND_DIR, "trade.html"))
# --- API ---
from master_loader import master_loader
from database import Watchlist
@app.get("/api/sync/status")
def get_sync_status():
return master_loader.get_status()
@app.post("/api/sync/master")
def sync_master_data(background_tasks: fastapi.BackgroundTasks):
# Run in background
background_tasks.add_task(master_loader.download_and_parse_domestic)
background_tasks.add_task(master_loader.download_and_parse_overseas)
return {"status": "started", "message": "Master data sync started in background"}
@app.get("/api/stocks")
def search_stocks(keyword: str = "", market: str = "", page: int = 1, db: Session = Depends(get_db)):
query = db.query(Stock)
if keyword:
query = query.filter(Stock.name.contains(keyword) | Stock.code.contains(keyword))
if market:
query = query.filter(Stock.market == market)
limit = 50
offset = (page - 1) * limit
items = query.limit(limit).offset(offset).all()
return {"items": items}
@app.get("/api/watchlist")
def get_watchlist(db: Session = Depends(get_db)):
return db.query(Watchlist).order_by(Watchlist.created_at.desc()).all()
@app.post("/api/watchlist")
def add_watchlist(code: str = Body(...), name: str = Body(...), market: str = Body(...), db: Session = Depends(get_db)):
exists = db.query(Watchlist).filter(Watchlist.code == code).first()
if exists: return {"status": "exists"}
item = Watchlist(code=code, name=name, market=market)
db.add(item)
db.commit()
return {"status": "added"}
@app.delete("/api/watchlist/{code}")
def delete_watchlist(code: str, db: Session = Depends(get_db)):
db.query(Watchlist).filter(Watchlist.code == code).delete()
db.commit()
return {"status": "deleted"}
@app.get("/api/settings")
def get_settings():
return load_config()
@app.post("/api/settings")
def update_settings(settings: dict = Body(...)):
save_config(settings)
return {"status": "ok"}
from database import AccountBalance, Holding
import datetime
@app.get("/api/balance")
def get_my_balance(source: str = "db", db: Session = Depends(get_db)):
"""
Return persisted balance and holdings from DB.
Structure similar to KIS API to minimize frontend changes, or simplified.
"""
# 1. Balance Summary
acc = db.query(AccountBalance).first()
output2 = []
if acc:
output2.append({
"tot_evlu_amt": acc.total_eval,
"dnca_tot_amt": acc.deposit,
"evlu_pfls_smtl_amt": acc.total_profit
})
# 2. Holdings (Domestic)
holdings = db.query(Holding).filter(Holding.market == 'DOMESTIC').all()
output1 = []
for h in holdings:
output1.append({
"pdno": h.code,
"prdt_name": h.name,
"hldg_qty": h.quantity,
"prpr": h.current_price,
"pchs_avg_pric": h.price,
"evlu_pfls_rt": h.profit_rate
})
return {
"rt_cd": "0",
"msg1": "Success from DB",
"output1": output1,
"output2": output2
}
@app.get("/api/balance/overseas")
def get_my_overseas_balance(db: Session = Depends(get_db)):
# Persisted Overseas Holdings
holdings = db.query(Holding).filter(Holding.market == 'NASD').all()
output1 = []
for h in holdings:
output1.append({
"ovrs_pdno": h.code,
"ovrs_item_name": h.name,
"ovrs_cblc_qty": h.quantity,
"now_pric2": h.current_price,
"frcr_pchs_amt1": h.price,
"evlu_pfls_rt": h.profit_rate
})
return {
"rt_cd": "0",
"output1": output1
}
@app.post("/api/balance/refresh")
def force_refresh_balance(background_tasks: BackgroundTasks):
trader.refresh_assets() # Run synchronously to return fresh data immediately? Or BG?
# User perception: "Loading..." -> Show data.
# If we run BG, frontend needs to poll.
# Let's run Sync for "Refresh" button (unless it takes too long).
# KIS API is reasonably fast (milliseconds).
# trader.refresh_assets()
return {"status": "ok"}
@app.get("/api/price/{code}")
def get_stock_price(code: str):
price = kis.get_current_price(code)
if price:
return price
raise HTTPException(status_code=404, detail="Stock info not found")
@app.post("/api/order")
def place_order_api(
code: str = Body(...),
type: str = Body(...),
qty: int = Body(...),
price: int = Body(...),
market: str = Body("DOMESTIC")
):
if market in ["NASD", "NYSE", "AMEX"]:
res = kis.place_overseas_order(code, type, qty, price, market)
else:
res = kis.place_order(code, type, qty, price)
if res and res.get('rt_cd') == '0':
return res
raise HTTPException(status_code=400, detail=f"Order Failed: {res}")
@app.get("/api/orders")
def get_db_orders(db: Session = Depends(get_db)):
orders = db.query(Order).order_by(Order.created_at.desc()).limit(50).all()
return orders
@app.get("/api/orders/daily")
def get_daily_orders_api():
res = kis.get_daily_orders()
if res:
return res
raise HTTPException(status_code=500, detail="Failed to fetch daily orders")
@app.get("/api/orders/cancelable")
def get_cancelable_orders_api():
res = kis.get_cancelable_orders()
if res:
return res
raise HTTPException(status_code=500, detail="Failed to fetch cancelable orders")
@app.post("/api/order/cancel")
def cancel_order_api(
org_no: str = Body(...),
order_no: str = Body(...),
qty: int = Body(...),
is_buy: bool = Body(...),
price: int = Body(0)
):
res = kis.cancel_order(org_no, order_no, qty, is_buy, price)
if res and res.get('rt_cd') == '0':
return res
raise HTTPException(status_code=400, detail=f"Cancel Failed: {res}")
@app.get("/api/news")
def get_news(db: Session = Depends(get_db)):
news = db.query(News).order_by(News.created_at.desc()).limit(20).all()
return news
@app.get("/api/trade_settings")
def get_trade_settings(db: Session = Depends(get_db)):
return db.query(TradeSetting).all()
@app.post("/api/trade_settings")
def set_trade_setting(
code: str = Body(...),
target_price: Optional[float] = Body(None),
stop_loss_price: Optional[float] = Body(None),
is_active: bool = Body(True),
db: Session = Depends(get_db)
):
setting = db.query(TradeSetting).filter(TradeSetting.code == code).first()
if not setting:
setting = TradeSetting(code=code)
db.add(setting)
if target_price is not None:
setting.target_price = target_price
if stop_loss_price is not None:
setting.stop_loss_price = stop_loss_price
setting.is_active = is_active
db.commit()
return {"status": "ok"}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

253
backend/master_loader.py Normal file
View File

@@ -0,0 +1,253 @@
import os
import requests
import zipfile
import io
import pandas as pd
from database import SessionLocal, Stock, engine
from sqlalchemy.orm import Session
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("MASTER_LOADER")
class MasterLoader:
def __init__(self):
self.base_dir = os.path.dirname(os.path.abspath(__file__))
self.tmp_dir = os.path.join(self.base_dir, "tmp_master")
if not os.path.exists(self.tmp_dir):
os.makedirs(self.tmp_dir)
self.sync_status = {"status": "idle", "message": ""}
def get_status(self):
return self.sync_status
def _set_status(self, status, message):
self.sync_status = {"status": status, "message": message}
logger.info(f"Sync Status: {status} - {message}")
def download_and_parse_domestic(self):
self._set_status("running", "Downloading Domestic Master...")
urls = {
"kospi": "https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip",
"kosdaq": "https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip"
}
db = SessionLocal()
try:
for market, url in urls.items():
logger.info(f"Downloading {market} master data from {url}...")
try:
res = requests.get(url)
if res.status_code != 200:
logger.error(f"Failed to download {market} master")
self._set_status("error", f"Failed to download {market}")
continue
with zipfile.ZipFile(io.BytesIO(res.content)) as z:
filename = f"{market}_code.mst"
z.extract(filename, self.tmp_dir)
file_path = os.path.join(self.tmp_dir, filename)
self._parse_domestic_file(file_path, market.upper(), db)
except Exception as e:
logger.error(f"Error processing {market}: {e}")
self._set_status("error", f"Error processing {market}: {e}")
db.commit()
if self.sync_status['status'] != 'error':
self._set_status("running", "Domestic Sync Complete")
finally:
db.close()
def _parse_domestic_file(self, file_path, market_name, db: Session):
with open(file_path, 'r', encoding='cp949') as f:
lines = f.readlines()
logger.info(f"Parsing {len(lines)} lines for {market_name}...")
batch = []
for line in lines:
code = line[0:9].strip()
name = line[21:61].strip()
if not code or not name:
continue
batch.append({
"code": code,
"name": name,
"market": market_name,
"type": "DOMESTIC"
})
if len(batch) >= 1000:
self._upsert_batch(db, batch)
batch = []
if batch:
self._upsert_batch(db, batch)
def download_and_parse_overseas(self):
if self.sync_status['status'] == 'error': return
self._set_status("running", "Downloading Overseas Master...")
# NASDAQ from text file
urls = {
"NASD": "https://www.nasdaqtrader.com/dynamic/symdir/nasdaqlisted.txt",
# "NYSE": "https://new.real.download.dws.co.kr/common/master/usa_nys.mst.zip",
# "AMEX": "https://new.real.download.dws.co.kr/common/master/usa_ams.mst.zip"
}
db = SessionLocal()
error_count = 0
try:
for market, url in urls.items():
logger.info(f"Downloading {market} master data from {url}...")
try:
res = requests.get(url)
logger.info(f"HTTP Status: {res.status_code}")
if res.status_code != 200:
logger.error(f"Download failed for {market}. Status: {res.status_code}")
error_count += 1
continue
if url.endswith('.txt'):
self._parse_nasdaq_txt(res.text, market, db)
else:
with zipfile.ZipFile(io.BytesIO(res.content)) as z:
target_file = None
for f in z.namelist():
if f.endswith(".mst"):
target_file = f
break
if target_file:
z.extract(target_file, self.tmp_dir)
file_path = os.path.join(self.tmp_dir, target_file)
self._parse_overseas_file(file_path, market, db)
except Exception as e:
logger.error(f"Error processing {market}: {e}")
error_count += 1
db.commit()
if error_count == len(urls):
self._set_status("error", "All overseas downloads failed.")
elif error_count > 0:
self._set_status("warning", f"Overseas Sync Partial ({error_count} failed).")
else:
self._set_status("done", "All Sync Complete.")
finally:
db.close()
def _parse_nasdaq_txt(self, content, market_name, db: Session):
# Format: Symbol|Security Name|Market Category|Test Issue|Financial Status|Round Lot Size|ETF|NextShares
lines = content.splitlines()
logger.info(f"Parsing {len(lines)} lines for {market_name} (TXT)...")
batch = []
parsed_count = 0
for line in lines:
try:
if not line or line.startswith('Symbol|') or line.startswith('File Creation Time'):
continue
parts = line.split('|')
if len(parts) < 7: continue
symbol = parts[0]
name = parts[1]
# market_category = parts[2]
financial_status = parts[4] # N=Normal, D=Deficient, E=Delinquent, Q=Bankrupt, G=Deficient and Bankrupt
etf_flag = parts[6] # Y/N
is_etf = (etf_flag == 'Y')
batch.append({
"code": symbol,
"name": name,
"name_eng": name,
"market": market_name,
"type": "OVERSEAS",
"financial_status": financial_status,
"is_etf": is_etf
})
if len(batch) >= 1000:
self._upsert_batch(db, batch)
parsed_count += len(batch)
batch = []
except Exception as e:
# logger.error(f"Parse error: {e}")
continue
if batch:
self._upsert_batch(db, batch)
parsed_count += len(batch)
logger.info(f"Parsed and Upserted {parsed_count} items for {market_name}")
def _parse_overseas_file(self, file_path, market_name, db: Session):
with open(file_path, 'r', encoding='cp949', errors='ignore') as f:
lines = f.readlines()
logger.info(f"Parsing {len(lines)} lines for {market_name}... (File: {os.path.basename(file_path)})")
batch = []
parsed_count = 0
for line in lines:
try:
b_line = line.encode('cp949')
symbol = b_line[0:16].decode('cp949').strip()
name_eng = b_line[16:80].decode('cp949').strip()
if not symbol: continue
batch.append({
"code": symbol,
"name": name_eng,
"name_eng": name_eng,
"market": market_name,
"type": "OVERSEAS"
})
if len(batch) >= 1000:
self._upsert_batch(db, batch)
parsed_count += len(batch)
batch = []
except Exception as e:
continue
if batch:
self._upsert_batch(db, batch)
parsed_count += len(batch)
logger.info(f"Parsed and Upserted {parsed_count} items for {market_name}")
def _upsert_batch(self, db: Session, batch):
for item in batch:
existing = db.query(Stock).filter(Stock.code == item['code']).first()
if existing:
existing.name = item['name']
existing.market = item['market']
existing.type = item['type']
if 'name_eng' in item: existing.name_eng = item['name_eng']
if 'financial_status' in item: existing.financial_status = item['financial_status']
if 'is_etf' in item: existing.is_etf = item['is_etf']
else:
stock = Stock(**item)
db.add(stock)
db.commit()
master_loader = MasterLoader()
if __name__ == "__main__":
print("Starting sync...")
master_loader.download_and_parse_domestic()
print("Domestic Done. Starting Overseas...")
master_loader.download_and_parse_overseas()
print("Sync Complete.")

37
backend/migrate_db.py Normal file
View File

@@ -0,0 +1,37 @@
import sqlite3
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(os.path.dirname(BASE_DIR), 'kis_stock.db')
def migrate():
print(f"Migrating database at {DB_PATH}...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check if columns exist, if not add them
try:
cursor.execute("ALTER TABLE stocks ADD COLUMN name_eng VARCHAR")
print("Added name_eng")
except Exception as e:
print(f"Skipping name_eng: {e}")
try:
cursor.execute("ALTER TABLE stocks ADD COLUMN industry VARCHAR")
print("Added industry")
except Exception as e:
print(f"Skipping industry: {e}")
try:
cursor.execute("ALTER TABLE stocks ADD COLUMN type VARCHAR DEFAULT 'DOMESTIC'")
print("Added type")
except Exception as e:
print(f"Skipping type: {e}")
conn.commit()
conn.close()
print("Migration complete.")
if __name__ == "__main__":
migrate()

57
backend/migrate_db_v2.py Normal file
View File

@@ -0,0 +1,57 @@
import sqlite3
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(os.path.dirname(BASE_DIR), 'kis_stock.db')
def migrate():
print(f"Migrating database v2 at {DB_PATH}...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Financial Status (Health)
try:
cursor.execute("ALTER TABLE stocks ADD COLUMN financial_status VARCHAR")
print("Added financial_status")
except Exception as e:
print(f"Skipping financial_status: {e}")
# ETF Facet
try:
cursor.execute("ALTER TABLE stocks ADD COLUMN is_etf BOOLEAN DEFAULT 0")
print("Added is_etf")
except Exception as e:
print(f"Skipping is_etf: {e}")
# Current Price (Cache)
try:
cursor.execute("ALTER TABLE stocks ADD COLUMN current_price FLOAT DEFAULT 0")
print("Added current_price")
except Exception as e:
print(f"Skipping current_price: {e}")
# Stock Price History Table
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS stock_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code VARCHAR NOT NULL,
price FLOAT,
change FLOAT,
volume INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_stock_prices_code ON stock_prices (code)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_stock_prices_created_at ON stock_prices (created_at)")
print("Created stock_prices table")
except Exception as e:
print(f"Error creating stock_prices: {e}")
conn.commit()
conn.close()
print("Migration v2 complete.")
if __name__ == "__main__":
migrate()

18
backend/migrate_db_v3.py Normal file
View File

@@ -0,0 +1,18 @@
from sqlalchemy import create_engine, text
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB_URL = f"sqlite:///{os.path.join(BASE_DIR, 'kis_stock.db')}"
engine = create_engine(DB_URL)
def migrate():
with engine.connect() as conn:
try:
conn.execute(text("ALTER TABLE watchlist ADD COLUMN is_monitoring BOOLEAN DEFAULT 1"))
print("Added is_monitoring column to watchlist")
except Exception as e:
print(f"Column might already exist: {e}")
if __name__ == "__main__":
migrate()

147
backend/news_ai.py Normal file
View File

@@ -0,0 +1,147 @@
import time
import threading
import logging
import requests
import json
from sqlalchemy.orm import Session
from database import SessionLocal, News, Stock
from config import get_kis_config, load_config
logger = logging.getLogger("NEWS_AI")
class NewsBot:
def __init__(self):
self.is_running = False
self.thread = None
self.config = load_config()
self.naver_id = self.config.get('naver', {}).get('client_id', '')
self.naver_secret = self.config.get('naver', {}).get('client_secret', '')
self.google_key = self.config.get('google', {}).get('api_key', '')
def start(self):
if self.is_running:
return
self.is_running = True
self.thread = threading.Thread(target=self._run_loop, daemon=True)
self.thread.start()
logger.info("News Bot Started")
def stop(self):
self.is_running = False
if self.thread:
self.thread.join()
logger.info("News Bot Stopped")
def _run_loop(self):
while self.is_running:
try:
# Reload config to check current settings
self.config = load_config()
if self.config.get('preferences', {}).get('enable_news', False):
self._fetch_and_analyze()
else:
logger.info("News collection is disabled.")
except Exception as e:
logger.error(f"Error in news loop: {e}")
# Sleep 10 minutes (600 seconds)
for _ in range(600):
if not self.is_running: break
time.sleep(1)
def _fetch_and_analyze(self):
logger.info("Fetching News...")
if not self.naver_id or not self.naver_secret:
logger.warning("Naver API Credentials missing.")
return
# 1. Fetch News (Naver)
# Search for generic economy terms or specific watchlist
query = "주식 시장" # General Stock Market
url = "https://openapi.naver.com/v1/search/news.json"
headers = {
"X-Naver-Client-Id": self.naver_id,
"X-Naver-Client-Secret": self.naver_secret
}
params = {"query": query, "display": 10, "sort": "date"}
res = requests.get(url, headers=headers, params=params)
if res.status_code != 200:
logger.error(f"Naver News Failed: {res.text}")
return
items = res.json().get('items', [])
db = SessionLocal()
try:
for item in items:
title = item['title']
link = item['originallink'] or item['link']
pub_date = item['pubDate']
# Check duplication
if db.query(News).filter(News.link == link).first():
continue
# 2. AI Analysis (Google Gemini)
analysis = self._analyze_with_ai(title, item['description'])
# Save to DB
news = News(
title=title,
link=link,
pub_date=pub_date,
analysis_result=analysis.get('summary', ''),
impact_score=analysis.get('score', 0),
related_sector=analysis.get('sector', '')
)
db.add(news)
db.commit()
logger.info(f"Processed {len(items)} news items.")
finally:
db.close()
def _analyze_with_ai(self, title, description):
if not self.google_key:
return {"summary": "No API Key", "score": 0, "sector": ""}
logger.info(f"Analyzing: {title[:30]}...")
# Prompt
prompt = f"""
Analyze the following news for stock market impact.
Title: {title}
Description: {description}
Return JSON format:
{{
"summary": "One line summary of impact",
"score": Integer between -10 (Negative) to 10 (Positive),
"sector": "Related Industry/Sector or 'None'"
}}
"""
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key={self.google_key}"
headers = {"Content-Type": "application/json"}
body = {
"contents": [{
"parts": [{"text": prompt}]
}]
}
try:
res = requests.post(url, headers=headers, data=json.dumps(body))
if res.status_code == 200:
result = res.json()
text = result['candidates'][0]['content']['parts'][0]['text']
# Clean markdown json if any
text = text.replace("```json", "").replace("```", "").strip()
return json.loads(text)
else:
logger.error(f"Gemini API Error: {res.text}")
except Exception as e:
logger.error(f"AI Analysis Exception: {e}")
return {"summary": "Error", "score": 0, "sector": ""}
news_bot = NewsBot()

View File

@@ -0,0 +1,43 @@
import requests
import logging
from config import load_config
logger = logging.getLogger("TELEGRAM")
class TelegramNotifier:
def __init__(self):
self.config = load_config()
self.bot_token = self.config.get('telegram', {}).get('bot_token', '')
self.chat_id = self.config.get('telegram', {}).get('chat_id', '')
def reload_config(self):
self.config = load_config()
self.bot_token = self.config.get('telegram', {}).get('bot_token', '')
self.chat_id = self.config.get('telegram', {}).get('chat_id', '')
def send_message(self, text):
# Reload to ensure we have latest from settings
self.reload_config()
# Check if enabled
if not self.config.get('preferences', {}).get('enable_telegram', True):
return
if not self.bot_token or not self.chat_id:
logger.warning("Telegram credentials missing.")
return
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
payload = {
"chat_id": self.chat_id,
"text": text
}
try:
res = requests.post(url, json=payload, timeout=5)
if res.status_code != 200:
logger.error(f"Telegram Send Failed: {res.text}")
except Exception as e:
logger.error(f"Telegram Error: {e}")
notifier = TelegramNotifier()

View File

@@ -0,0 +1,29 @@
import sys
import os
# Ensure current dir is in path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from database import init_db, SessionLocal, AccountBalance, Holding, engine
from trader import trader
import logging
logging.basicConfig(level=logging.INFO)
def test_db_migration():
print("Initializing DB...")
init_db()
# Check tables
from sqlalchemy import inspect
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"Tables: {tables}")
if "account_balance" in tables and "holdings" in tables:
print("PASS: New tables created.")
else:
print("FAIL: Tables missing.")
if __name__ == "__main__":
test_db_migration()

View File

@@ -0,0 +1,43 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from telegram_notifier import notifier
from config import save_config, load_config
def test_telegram_toggle():
print("Testing Telegram Toggle...")
original_config = load_config()
try:
# 1. Enable
print("1. Testing ENABLED...")
cfg = load_config()
if 'preferences' not in cfg: cfg['preferences'] = {}
cfg['preferences']['enable_telegram'] = True
save_config(cfg)
# We can't easily mock requests.post here without importing mock,
# but we can check if it attempts to read credentials.
# Ideally, we'd check if it returns early.
# For this environment, let's just ensure no crash.
notifier.send_message("Test Message (Should Send)")
# 2. Disable
print("2. Testing DISABLED...")
cfg['preferences']['enable_telegram'] = False
save_config(cfg)
# This should return early and NOT log "Telegram credentials missing" if implemented right.
notifier.send_message("Test Message (Should NOT Send)")
print("Toggle logic executed without error.")
finally:
# Restore
save_config(original_config)
print("Original config restored.")
if __name__ == "__main__":
test_telegram_toggle()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

214
backend/trader.py Normal file
View File

@@ -0,0 +1,214 @@
import time
import threading
import logging
from sqlalchemy.orm import Session
from database import SessionLocal, TradeSetting, Order, Stock, AccountBalance, Holding
import datetime
from kis_api import kis
from telegram_notifier import notifier
logger = logging.getLogger("TRADER")
class TradingBot:
def __init__(self):
self.is_running = False
self.thread = None
self.holdings = {} # Local cache for holdings: {code: {qty: int, price: float}}
self.last_chart_update = 0
def refresh_assets(self):
"""
Fetch Balance and Holdings from KIS and save to DB
"""
logger.info("Syncing Assets to Database...")
db = SessionLocal()
try:
# 1. Domestic Balance
balance = kis.get_balance(source="Automated_Sync")
if balance and 'output2' in balance and balance['output2']:
summary = balance['output2'][0]
# Upsert AccountBalance
fn_status = db.query(AccountBalance).first()
if not fn_status:
fn_status = AccountBalance()
db.add(fn_status)
fn_status.total_eval = float(summary['tot_evlu_amt'])
fn_status.deposit = float(summary['dnca_tot_amt'])
fn_status.total_profit = float(summary['evlu_pfls_smtl_amt'])
fn_status.updated_at = datetime.datetime.now()
# 2. Holdings (Domestic)
# Clear existing DOMESTIC
db.query(Holding).filter(Holding.market == 'DOMESTIC').delete()
if balance and 'output1' in balance:
self.holdings = {} # Keep memory cache for trading logic
for item in balance['output1']:
code = item['pdno']
qty = int(item['hldg_qty'])
if qty > 0:
buy_price = float(item['pchs_avg_pric'])
current_price = float(item['prpr'])
profit_rate = float(item['evlu_pfls_rt'])
# Save to DB
db.add(Holding(
code=code,
name=item['prdt_name'],
quantity=qty,
price=buy_price,
current_price=current_price,
profit_rate=profit_rate,
market="DOMESTIC"
))
# Memory Cache for Trade Logic
self.holdings[code] = {'qty': qty, 'price': buy_price}
# 3. Overseas Balance (NASD default)
# TODO: Multi-market support if needed
overseas = kis.get_overseas_balance(exchange="NASD")
# Clear existing NASD
db.query(Holding).filter(Holding.market == 'NASD').delete()
if overseas and 'output1' in overseas:
for item in overseas['output1']:
qty = float(item['ovrs_cblc_qty']) # Overseas can be fractional? KIS is usually int but check.
if qty > 0:
code = item['ovrs_pdno']
# name = item.get('ovrs_item_name') or item.get('prdt_name')
# KIS overseas output keys vary.
db.add(Holding(
code=code,
name=item.get('ovrs_item_name', code),
quantity=int(qty),
price=float(item.get('frcr_pchs_amt1', 0)), # Avg Price? Check API
current_price=float(item.get('now_pric2', 0)),
profit_rate=float(item.get('evlu_pfls_rt', 0)),
market="NASD"
))
db.commit()
logger.info("Assets Synced Successfully.")
except Exception as e:
logger.error(f"Failed to sync assets: {e}")
db.rollback()
finally:
db.close()
def start(self):
if self.is_running:
return
self.is_running = True
self.refresh_assets() # Fetch on start
self.thread = threading.Thread(target=self._run_loop, daemon=True)
self.thread.start()
logger.info("Trading Bot Started")
def stop(self):
self.is_running = False
if self.thread:
self.thread.join()
logger.info("Trading Bot Stopped")
def _run_loop(self):
while self.is_running:
try:
self._process_cycle()
except Exception as e:
logger.error(f"Error in trading loop: {e}")
# Sleep 1 second to avoid hammering
time.sleep(1)
def _process_cycle(self):
db = SessionLocal()
try:
# Get active trade settings
settings = db.query(TradeSetting).filter(TradeSetting.is_active == True).all()
for setting in settings:
self._check_and_trade(db, setting)
finally:
db.close()
def _check_and_trade(self, db: Session, setting: TradeSetting):
code = setting.code
# Get Current Price
# Optimization: Ideally read from a shared cache from WebSocket
# For now, we still poll price or should use WS logic?
# User said "Websocket... automatic decision".
# But trader.py is isolated.
# For simplicity in this step (removing balance poll), we keep price fetch but remove balance poll.
price_data = kis.get_current_price(code)
if not price_data:
return
current_price = float(price_data.get('stck_prpr', 0))
if current_price == 0:
return
# Check holdings from Cache
if code not in self.holdings:
return # No holdings, nothing to sell (if logic is Sell)
holding = self.holdings[code]
holding_qty = holding['qty']
# SELL Logic
if holding_qty > 0:
# Stop Loss
if setting.stop_loss_price and current_price <= setting.stop_loss_price:
logger.info(f"Stop Loss Triggered for {code}. Price: {current_price}, SL: {setting.stop_loss_price}")
self._place_order(db, code, 'sell', holding_qty, 0) # 0 means Market Price
return
# Target Profit
if setting.target_price and current_price >= setting.target_price:
logger.info(f"Target Price Triggered for {code}. Price: {current_price}, TP: {setting.target_price}")
self._place_order(db, code, 'sell', holding_qty, 0)
return
def _place_order(self, db: Session, code: str, type: str, qty: int, price: int):
logger.info(f"Placing Order: {code} {type} {qty} @ {price}")
res = kis.place_order(code, type, qty, price)
status = "FAILED"
order_id = ""
if res and res.get('rt_cd') == '0':
status = "PENDING"
order_id = res.get('output', {}).get('ODNO', '')
logger.info(f"Order Success: {order_id}")
notifier.send_message(f"🔔 주문 전송 완료\n[{type.upper()}] {code}\n수량: {qty}\n가격: {price if price > 0 else '시장가'}")
# Optimistic Update or Refresh?
# User said "If execution happens, update list".
# We should schedule a refresh.
time.sleep(1) # Wait for execution
self.refresh_assets()
else:
logger.error(f"Order Failed: {res}")
notifier.send_message(f"⚠️ 주문 실패\n[{type.upper()}] {code}\n이유: {res}")
# Record to DB
new_order = Order(
code=code,
order_id=order_id,
type=type.upper(),
price=price,
quantity=qty,
status=status
)
db.add(new_order)
db.commit()
trader = TradingBot()

View File

@@ -0,0 +1,182 @@
import asyncio
import websockets
import json
import logging
import datetime
from typing import List, Set
from database import SessionLocal, Stock, StockPrice
from kis_api import kis
logger = logging.getLogger("WEBSOCKET")
class KisWebSocketManager:
def __init__(self):
self.active_frontend_connections: List[any] = []
self.subscribed_codes: Set[str] = set()
self.running = False
self.approval_key = None
self.msg_queue = asyncio.Queue() # For outgoing subscription requests
# KIS Environment
if kis.is_paper:
self.url = "ws://ops.koreainvestment.com:31000"
else:
self.url = "ws://ops.koreainvestment.com:21000"
# ... (connect/disconnect/broadcast remains same)
# ... (connect/disconnect/broadcast remains same)
# ... (_handle_realtime_data remains same)
async def subscribe_stock(self, code):
if code in self.subscribed_codes:
return
self.subscribed_codes.add(code)
await self.msg_queue.put(code)
logger.info(f"Queued Subscription for {code}")
async def connect_frontend(self, websocket):
await websocket.accept()
self.active_frontend_connections.append(websocket)
logger.info(f"Frontend Client Connected. Total: {len(self.active_frontend_connections)}")
def disconnect_frontend(self, websocket):
if websocket in self.active_frontend_connections:
self.active_frontend_connections.remove(websocket)
logger.info("Frontend Client Disconnected")
async def broadcast_to_frontend(self, message: dict):
# Broadcast to all connected frontend clients
for connection in self.active_frontend_connections:
try:
await connection.send_json(message)
except Exception as e:
logger.error(f"Broadcast error: {e}")
self.disconnect_frontend(connection)
async def start_kis_socket(self):
self.running = True
logger.info(f"Starting KIS WebSocket Service... Target: {self.url}")
while self.running:
# 1. Ensure Approval Key
if not self.approval_key:
self.approval_key = kis.get_websocket_key()
if not self.approval_key:
logger.error("Failed to get WebSocket Approval Key. Retrying in 10s...")
await asyncio.sleep(10)
continue
logger.info(f"Got WS Key: {self.approval_key[:10]}...")
# 2. Connect
try:
# KIS doesn't use standard ping frames often, handle manually or disable auto-ping
async with websockets.connect(self.url, ping_interval=None, open_timeout=20) as ws:
logger.info("Connected to KIS WebSocket Server")
# Process initial subscriptions
for code in self.subscribed_codes:
await self._send_subscription(ws, code)
while self.running:
try:
# 1. Check for incoming data
msg = await asyncio.wait_for(ws.recv(), timeout=0.1)
# PING/PONG (String starting with 0 or 1 usually means data)
if msg[0] in ['0', '1']:
await self._handle_realtime_data(msg)
else:
# JSON Message (System, PINGPONG)
try:
data = json.loads(msg)
if data.get('header', {}).get('tr_id') == 'PINGPONG':
await ws.send(msg) # Echo back
continue
except:
pass
except asyncio.TimeoutError:
pass
except websockets.ConnectionClosed:
logger.warning("KIS WS Closed. Reconnecting...")
break
except Exception as e:
logger.error(f"WS Connection Error: {e}")
# If auth failed (maybe expired key?), clear key to force refresh
# simplified check: if "Approval key" error in exception message?
# For now just retry.
await asyncio.sleep(5)
async def _handle_realtime_data(self, msg: str):
# Format: 0|TR_ID|DATA_CNT|Code^Time^Price...
try:
parts = msg.split('|')
if len(parts) < 4: return
tr_id = parts[1]
data_part = parts[3]
if tr_id == "H0STCNT0": # Domestic Stock Price
# Data format: Code^Time^CurrentPrice^Sign^Change...
# Actually, data_part is delimiter separated.
values = data_part.split('^')
code = values[0]
price = values[2]
change = values[4]
rate = values[5]
# Broadcast
payload = {
"type": "PRICE",
"code": code,
"price": price,
"change": change,
"rate": rate,
"timestamp": datetime.datetime.now().isoformat()
}
await self.broadcast_to_frontend(payload)
# Update DB (Optional? Too frequent writes maybe bad)
# Let's save only significant updates or throttle?
# For now just log/broadcast.
except Exception as e:
logger.error(f"Data Parse Error: {e} | Msg: {msg[:50]}")
async def subscribe_stock(self, code):
if code in self.subscribed_codes:
return
self.subscribed_codes.add(code)
# If socket is active, send subscription (Implementation complexity: need access to active 'ws' object)
# Will handle by restarting connection or using a queue?
# Better: just set it in set, and the main loop will pick it up on reconnect,
# BUT for immediate sub, we need a way to signal the running loop.
# For MVP, let's assume we subscribe on startup or bulk.
# Real-time dynamic sub needs a queue.
logger.info(f"Subscribed to {code} (Pending next reconnect/sweep)")
async def _send_subscription(self, ws, code):
# Domestic Stock Realtime Price: H0STCNT0
body = {
"header": {
"approval_key": self.approval_key,
"custtype": "P",
"tr_type": "1", # 1: Register, 2: Unregister
"content-type": "utf-8"
},
"body": {
"input": {
"tr_id": "H0STCNT0",
"tr_key": code
}
}
}
await ws.send(json.dumps(body))
logger.info(f"Sent Subscription Request for {code}")
ws_manager = KisWebSocketManager()

228
frontend/css/style.css Normal file
View File

@@ -0,0 +1,228 @@
:root {
--bg-color: #0f172a;
--card-bg: rgba(30, 41, 59, 0.7);
--card-border: 1px solid rgba(255, 255, 255, 0.1);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent-color: #3b82f6;
--accent-glow: 0 0 20px rgba(59, 130, 246, 0.5);
--success-color: #10b981;
--danger-color: #ef4444;
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: var(--bg-color);
background-image: radial-gradient(circle at 10% 20%, rgba(59, 130, 246, 0.1) 0%, transparent 20%),
radial-gradient(circle at 90% 80%, rgba(16, 185, 129, 0.05) 0%, transparent 20%);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Navigation */
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(45deg, #3b82f6, #10b981);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.nav-links {
display: flex;
gap: 1.5rem;
}
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.nav-links a:hover, .nav-links a.active {
color: var(--text-primary);
text-shadow: 0 0 10px rgba(255,255,255,0.3);
}
/* Layout */
.container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
flex: 1;
}
/* Cards */
.card {
background: var(--card-bg);
border: var(--card-border);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--glass-shadow);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
.card h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
color: var(--text-primary);
border-bottom: 1px solid rgba(255,255,255,0.05);
padding-bottom: 0.5rem;
}
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
/* Table */
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
th {
color: var(--text-secondary);
font-weight: 600;
font-size: 0.9rem;
}
td {
color: var(--text-primary);
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 600;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: var(--accent-glow);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
}
.btn-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
}
/* Form */
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
input, select, textarea {
width: 100%;
padding: 0.8rem;
border-radius: 8px;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(255,255,255,0.1);
color: white;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* Utilities */
.text-success { color: var(--success-color); }
.text-danger { color: var(--danger-color); }
.text-muted { color: var(--text-secondary); }
/* Badge */
.badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-success { background: rgba(16, 185, 129, 0.2); color: #10b981; }
.badge-secondary { background: rgba(148, 163, 184, 0.2); color: #94a3b8; }
/* Mobile */
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
.nav-links { gap: 1rem; font-size: 0.9rem; }
}
/* Loading */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
-webkit-animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@-webkit-keyframes spin {
to { -webkit-transform: rotate(360deg); }
}

446
frontend/index.html Normal file
View File

@@ -0,0 +1,446 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KisStock AI Dashboard</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav>
<div class="logo">KisStock AI</div>
<div class="nav-links">
<a href="/" class="active">대시보드</a>
<a href="/trade">매매</a>
<a href="/news">AI뉴스</a>
<a href="/stocks">종목</a>
<a href="/settings">설정</a>
</div>
</nav>
<div class="container">
<!-- Balance Section -->
<div class="card">
<h2>💰 나의 자산</h2>
<div id="balance-info" class="loading-container">
<div class="grid">
<div>
<div class="text-muted">총 평가금액</div>
<div class="h2" id="total-eval">Loading...</div>
</div>
<div>
<div class="text-muted">예수금</div>
<div class="h2" id="deposit">Loading...</div>
</div>
<div>
<div class="text-muted">손익</div>
<div class="h2" id="total-profit">Loading...</div>
</div>
</div>
</div>
</div>
<div class="grid">
<!-- Watchlist Section -->
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h2>⭐ 관심 종목</h2>
<a href="/stocks" class="btn" style="padding:0.2rem 0.5rem; font-size:0.8rem;">종목 추가</a>
</div>
<div id="watchlist-container" class="grid" style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap:1rem; margin-top:1rem;">
Loading...
</div>
</div>
<!-- Holdings Section -->
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h2>📊 보유 종목</h2>
<div>
<button class="btn btn-sm" onclick="refreshHoldings()" style="margin-right:8px; padding:4px 8px; font-size:0.8rem; background:#475569;">🔄 조회</button>
<button id="btn-domestic" class="active-tab" onclick="showHoldingsTab('domestic')">국내</button>
<button id="btn-overseas" class="inactive-tab" onclick="showHoldingsTab('overseas')">해외(NASD)</button>
</div>
</div>
<style>
.active-tab { background: var(--accent-color); color:white; border:none; padding:4px 8px; border-radius:4px; font-weight:bold; cursor:pointer; }
.inactive-tab { background: transparent; color:#888; border:1px solid #555; padding:4px 8px; border-radius:4px; cursor:pointer; }
</style>
<div style="overflow-x:auto;">
<table id="holdings-table">
<thead>
<tr>
<th>종목명</th>
<th>잔고</th>
<th>현재가</th>
<th>손익률</th>
</tr>
</thead>
<tbody>
<!-- Rows -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Recent Orders -->
<div class="card">
<h2>📝 최근 주문 내역</h2>
<div style="overflow-x:auto;">
<table id="orders-table">
<thead>
<tr>
<th>시간</th>
<th>종목</th>
<th>구분</th>
<th>가격</th>
<th>수량</th>
<th>상태</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<script>
let currentHoldingsTab = 'domestic';
let lastFilledCount = -1;
function showHoldingsTab(tab) {
currentHoldingsTab = tab;
document.getElementById('btn-domestic').className = tab === 'domestic' ? 'active-tab' : 'inactive-tab';
document.getElementById('btn-overseas').className = tab === 'overseas' ? 'active-tab' : 'inactive-tab';
// Clear table and fetch immediately
document.querySelector('#holdings-table tbody').innerHTML = '<tr><td colspan="4">Loading...</td></tr>';
refreshHoldings();
}
async function refreshHoldings() {
const btn = document.querySelector('button[onclick="refreshHoldings()"]');
const originalText = btn.innerText;
btn.innerText = "⏳ 갱신 중...";
btn.disabled = true;
try {
// Trigger Backend Refresh
await fetch('/api/balance/refresh', { method: 'POST' });
// Then Load Data
if(currentHoldingsTab === 'domestic') {
await fetchBalance('manual_btn');
} else {
await fetchOverseasBalance();
}
} catch(e) {
console.error(e);
alert("갱신 실패");
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}
async function fetchWatchlist() {
try {
const res = await fetch('/api/watchlist');
const data = await res.json();
const container = document.getElementById('watchlist-container');
container.innerHTML = '';
// Switch to List/Table View
container.style.display = "block"; // Remove grid
container.style.width = "100%";
if (data.length === 0) {
container.innerHTML = "<div class='text-muted' style='text-align:center; padding:2rem;'>등록된 관심 종목이 없습니다.</div>";
return;
}
// Create Table Structure
const table = document.createElement('table');
table.style.width = "100%";
table.style.borderCollapse = "collapse";
table.innerHTML = `
<thead style="border-bottom: 1px solid rgba(255,255,255,0.1); color:#94a3b8; font-size:0.9rem;">
<tr>
<th style="padding:1rem; text-align:left;">종목</th>
<th style="padding:1rem; text-align:right;">현재가</th>
<th style="padding:1rem; text-align:right;">등락률</th>
<th style="padding:1rem; text-align:center;">빠른 매매</th>
<th style="padding:1rem; text-align:center;">관리</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector('tbody');
data.forEach(item => {
const tr = document.createElement('tr');
tr.id = `watch-${item.code}`;
tr.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
const isOverseas = ['NASD', 'NYSE', 'AMEX'].includes(item.market);
const marketBadge = `<span class="badge ${isOverseas?'text-danger':'text-success'}">${item.market}</span>`;
tr.innerHTML = `
<td style="padding:1rem;">
<div style="font-weight:bold; font-size:1.1rem;">${item.name}</div>
<div style="font-size:0.8rem; color:#64748b;">${item.code} ${marketBadge}</div>
</td>
<td style="padding:1rem; text-align:right;">
<span class="price-tag" style="font-size:1.2rem; font-weight:bold;">-</span>
</td>
<td style="padding:1rem; text-align:right;">
<span class="rate-tag" style="font-size:1rem;">0.00%</span>
</td>
<td style="padding:1rem; text-align:center;">
<div style="display:flex; gap:0.5rem; justify-content:center; align-items:center;">
<button class="btn btn-primary" style="padding:0.4rem 0.8rem;" onclick="goToTrade('${item.code}', '${item.market}', 'buy')">매수</button>
<button class="btn btn-danger" style="padding:0.4rem 0.8rem;" onclick="goToTrade('${item.code}', '${item.market}', 'sell')">매도</button>
</div>
</td>
<td style="padding:1rem; text-align:center;">
<button class="btn btn-danger" onclick="removeWatchlist('${item.code}')" style="padding:0.4rem; font-size:0.8rem;">삭제</button>
</td>
`;
tbody.appendChild(tr);
// Subscribe via WS (If Monitored)
if(item.is_monitoring && ws && ws.readyState === WebSocket.OPEN) {
ws.send(`sub:${item.code}`);
}
});
container.appendChild(table);
} catch(e) { console.error(e); }
}
function goToTrade(code, market, type) {
window.location.href = `/trade.html?code=${code}&market=${market}&type=${type}`;
}
async function removeWatchlist(code) {
if(!confirm("삭제하시겠습니까?")) return;
await fetch(`/api/watchlist/${code}`, { method: 'DELETE' });
fetchWatchlist();
}
async function fetchOverseasBalance() {
try {
const res = await fetch('/api/balance/overseas');
if(!res.ok) throw new Error("Check Server Logs");
const data = await res.json();
const tbody = document.querySelector('#holdings-table tbody');
tbody.innerHTML = '';
if (data.output1 && data.output1.length > 0) {
data.output1.forEach(item => {
const tr = document.createElement('tr');
const name = item.ovrs_item_name || item.prdt_name || item.ovrs_pdno;
const qty = item.ovrs_cblc_qty || item.ord_psbl_qty || item.hldg_qty;
const price = item.now_pric2 || item.prpr || 0;
const profitRate = parseFloat(item.evlu_pfls_rt || 0);
tr.innerHTML = `
<td>${name}</td>
<td>${qty}</td>
<td>$${parseFloat(price).toLocaleString()}</td>
<td class="${profitRate >= 0 ? 'text-success' : 'text-danger'}">${profitRate}%</td>
`;
tbody.appendChild(tr);
});
} else {
tbody.innerHTML = '<tr><td colspan="4">해외 보유 종목이 없습니다.</td></tr>';
}
} catch(e) {
console.error(e);
document.querySelector('#holdings-table tbody').innerHTML = `<tr><td colspan="4">조회 실패/보유없음</td></tr>`;
}
}
async function fetchBalance(source = 'unknown') {
console.log(`Fetching balance via ${source}`);
try {
const res = await fetch(`/api/balance?source=${source}`);
if(!res.ok) throw new Error("Server Error");
const data = await res.json();
if (data.rt_cd && data.rt_cd !== "0") {
throw new Error(data.msg1 || "Unknown API Error");
}
// Keep showing asset summary even if domestic tab is not active
if (data.output2 && data.output2[0]) {
const output2 = data.output2[0];
document.getElementById('total-eval').innerText = parseInt(output2.tot_evlu_amt).toLocaleString() + "원";
document.getElementById('deposit').innerText = parseInt(output2.dnca_tot_amt).toLocaleString() + "원";
const profit = parseInt(output2.evlu_pfls_smtl_amt);
const elProfit = document.getElementById('total-profit');
elProfit.innerText = profit.toLocaleString() + "원";
elProfit.className = profit >= 0 ? "h2 text-success" : "h2 text-danger";
}
if (currentHoldingsTab === 'domestic') {
const tbody = document.querySelector('#holdings-table tbody');
tbody.innerHTML = '';
if (data.output1 && data.output1.length > 0) {
data.output1.forEach(item => {
const tr = document.createElement('tr');
const profitRate = parseFloat(item.evlu_pfls_rt || 0);
tr.innerHTML = `
<td>${item.prdt_name} (${item.pdno})</td>
<td>${item.hldg_qty}</td>
<td>${parseInt(item.prpr).toLocaleString()}</td>
<td class="${profitRate >= 0 ? 'text-success' : 'text-danger'}">${profitRate}%</td>
`;
tbody.appendChild(tr);
});
} else {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">보유 종목이 없습니다.</td></tr>';
}
}
const now = new Date();
// We might not have this element in previous versions, but let's try safely if it exists, or ignore
// document.getElementById('sync-info').innerText = ...
// Removed to avoid error if element missing
} catch(e) {
console.error(e);
}
}
async function fetchOrders() {
try {
const res = await fetch('/api/orders');
const data = await res.json();
const tbody = document.querySelector('#orders-table tbody');
tbody.innerHTML = '';
let currentFilledCount = 0;
data.forEach(item => {
const tr = document.createElement('tr');
// Simple check for filled status
if (item.status === 'FILLED' || (item.status && item.status.includes('체결'))) {
currentFilledCount++;
}
tr.innerHTML = `
<td>${new Date(item.created_at).toLocaleString()}</td>
<td>${item.code}</td>
<td class="${item.type === 'BUY' ? 'text-danger' : 'text-success'}">${item.type}</td>
<td>${item.price.toLocaleString()}</td>
<td>${item.quantity}</td>
<td>${item.status}</td>
`;
tbody.appendChild(tr);
});
// If number of filled orders increased, refresh balance
// Skip the first check to avoid redundant refresh on page load
if (lastFilledCount === -1) {
lastFilledCount = currentFilledCount;
} else if (currentFilledCount > lastFilledCount) {
console.log("New order filled! Refreshing holdings...");
console.log(`Order count increased: ${lastFilledCount} -> ${currentFilledCount}`);
refreshHoldings();
lastFilledCount = currentFilledCount;
}
} catch(e) { console.error(e); }
}
// Init
fetchBalance('init');
fetchWatchlist();
fetchOrders();
// WebSocket Client
let ws = null;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("Connected to Server WS");
// Resend subscriptions for currently displayed items
const items = document.querySelectorAll('[id^="watch-"]');
items.forEach(el => {
const code = el.id.replace('watch-', '');
ws.send(`sub:${code}`);
});
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'PRICE') {
updatePriceDOM(msg);
}
};
ws.onclose = () => {
console.log("WS Disconnected. Reconnecting in 3s...");
setTimeout(connectWebSocket, 3000);
};
}
function updatePriceDOM(data) {
// data: {code, price, change, rate, ...}
// Find elements with data-code attribute? Or just iterate watchlist items?
// Currently our watchlist doesn't have IDs easily addressable.
// We generated: <div style="...">...Code...</div>
// Let's refine fetchWatchlist to add IDs or classes to help update.
// Update Watchlist Items
const watchItem = document.getElementById(`watch-${data.code}`);
if(watchItem) {
const diffColor = parseFloat(data.change) > 0 ? 'text-success' : (parseFloat(data.change) < 0 ? 'text-danger' : 'text-muted');
// Update price text. We need a specific span for price.
// Assuming we rebuild DOM to support this.
// For now, simpler implementation:
// Just flash the card or something?
// No, User wants real update.
const priceEl = watchItem.querySelector('.price-tag');
if(priceEl) {
priceEl.innerText = parseInt(data.price).toLocaleString();
priceEl.className = `price-tag ${diffColor}`;
}
const rateEl = watchItem.querySelector('.rate-tag');
if(rateEl) {
rateEl.innerText = `${data.rate}%`;
rateEl.className = `rate-tag ${diffColor}`;
}
}
}
// Enhance fetchWatchlist to support updates and subscribe
const originalFetchWatchlist = fetchWatchlist;
fetchWatchlist = async function() {
await originalFetchWatchlist(); // Call original
// After rendering, subscribe!
// Wait, original renders HTML. I need to modify it to include IDs.
};
connectWebSocket();
// Refresh Intervals
setInterval(fetchOrders, 30000); // 30s for orders
// setInterval(fetchWatchlist, 60000); // Disable polling if we use WS? Or keep as backup.
</script>
</body>
</html>

66
frontend/news.html Normal file
View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 뉴스 - KisStock AI</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav>
<div class="logo">KisStock AI</div>
<div class="nav-links">
<a href="/">대시보드</a>
<a href="/trade">매매</a>
<a href="/news" class="active">AI뉴스</a>
<a href="/stocks">종목</a>
<a href="/settings">설정</a>
</div>
</nav>
<div class="container">
<div class="card">
<h2>📰 실시간 AI 뉴스 분석</h2>
<div id="news-list">
Loading...
</div>
</div>
</div>
<script>
async function fetchNews() {
try {
const res = await fetch('/api/news');
const data = await res.json();
const container = document.getElementById('news-list');
container.innerHTML = '';
data.forEach(item => {
const div = document.createElement('div');
div.style.marginBottom = "1.5rem";
div.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
div.style.paddingBottom = "1rem";
const scoreColor = item.impact_score > 0 ? 'text-success' : (item.impact_score < 0 ? 'text-danger' : 'text-muted');
div.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<a href="${item.link}" target="_blank" style="color:var(--text-primary); text-decoration:none; font-size:1.1rem; font-weight:600;">${item.title.replace(/<[^>]*>?/gm, '')}</a>
<span class="badge ${scoreColor}" style="margin-left:8px; font-size:0.9rem;">AI Score: ${item.impact_score}</span>
</div>
<div class="text-muted" style="margin: 0.5rem 0;">${item.analysis_result || item.related_sector}</div>
<div style="display:flex; justify-content:space-between; font-size:0.8rem; color:#64748b;">
<span>${item.pub_date}</span>
<span>관련종목: ${item.related_sector || '분석중'}</span>
</div>
`;
container.appendChild(div);
});
} catch(e) { console.error(e); }
}
fetchNews();
setInterval(fetchNews, 30000);
</script>
</body>
</html>

188
frontend/settings.html Normal file
View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설정 - KisStock AI</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav>
<div class="logo">KisStock AI</div>
<div class="nav-links">
<a href="/">대시보드</a>
<a href="/trade">매매</a>
<a href="/settings" class="active">설정</a>
</div>
</nav>
<div class="container">
<div class="card">
<h2>⚙️ 시스템 설정</h2>
<div id="settings-form">
<!-- KIS Settings -->
<div class="form-group">
<label>App Key (실전/모의)</label>
<input type="text" id="app_key" placeholder="KIS App Key">
</div>
<div class="form-group">
<label>App Secret</label>
<input type="password" id="app_secret" placeholder="KIS App Secret">
</div>
<div class="grid">
<div class="form-group">
<label>계좌번호 (8자리)</label>
<input type="text" id="account_no" placeholder="12345678">
</div>
<div class="form-group">
<label>계좌식별코드 (2자리)</label>
<input type="text" id="account_prod" placeholder="01">
</div>
</div>
<div class="form-group">
<label>HTS ID</label>
<input type="text" id="htsid" placeholder="HTS ID">
</div>
<div class="form-group">
<label>투자 환경</label>
<select id="is_paper">
<option value="true">모의투자 (Mock)</option>
<option value="false">실전투자 (Real)</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="enable_news" style="width:auto;"> 뉴스 수집 및 AI 분석 활성화
</label>
</div>
<hr style="border-color: rgba(255,255,255,0.1); margin: 2rem 0;">
<!-- External APIs -->
<div class="form-group">
<label>Naver Client ID</label>
<input type="text" id="naver_id">
</div>
<div class="form-group">
<label>Naver Client Secret</label>
<input type="password" id="naver_secret">
</div>
<div class="form-group">
<label>Google Gemini API Key</label>
<input type="password" id="google_key">
</div>
<hr style="border-color: rgba(255,255,255,0.1); margin: 2rem 0;">
<div class="form-group">
<label>
<input type="checkbox" id="enable_telegram" style="width:auto;"> 텔레그램 알림 활성화
</label>
</div>
<div class="form-group">
<label>Telegram Bot Token</label>
<input type="password" id="bot_token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
</div>
<div class="form-group">
<label>Telegram Chat ID</label>
<input type="text" id="chat_id" placeholder="123456789">
</div>
<div style="text-align: right; margin-top: 2rem;">
<button class="btn btn-primary" onclick="saveSettings()">저장하기</button>
</div>
</div>
</div>
<div class="card">
<h2>🤖 자동매매 설정 (DB)</h2>
<div class="form-group">
<p class="text-muted">종목별 자동매매 감시 조건은 매매 페이지에서 설정할 수 있습니다.</p>
</div>
</div>
</div>
<script>
let currentConfig = {};
async function loadSettings() {
try {
const res = await fetch('/api/settings');
const data = await res.json();
currentConfig = data;
// Bind to UI
if (data.kis) {
document.getElementById('app_key').value = data.kis.app_key || '';
document.getElementById('app_secret').value = data.kis.app_secret || '';
document.getElementById('account_no').value = data.kis.account_no || '';
document.getElementById('account_prod').value = data.kis.account_prod || '';
document.getElementById('htsid').value = data.kis.htsid || '';
document.getElementById('is_paper').value = data.kis.is_paper ? "true" : "false";
}
if (data.preferences) {
document.getElementById('enable_news').checked = data.preferences.enable_news || false;
document.getElementById('enable_telegram').checked = data.preferences.enable_telegram !== false; // Default true
}
if (data.naver) {
document.getElementById('naver_id').value = data.naver.client_id || '';
document.getElementById('naver_secret').value = data.naver.client_secret || '';
}
if (data.google) {
document.getElementById('google_key').value = data.google.api_key || '';
}
if (data.telegram) {
document.getElementById('bot_token').value = data.telegram.bot_token || '';
document.getElementById('chat_id').value = data.telegram.chat_id || '';
}
} catch(e) { console.error(e); alert('Load failed'); }
}
async function saveSettings() {
const newConfig = { ...currentConfig };
if (!newConfig.kis) newConfig.kis = {};
newConfig.kis.app_key = document.getElementById('app_key').value;
newConfig.kis.app_secret = document.getElementById('app_secret').value;
newConfig.kis.account_no = document.getElementById('account_no').value;
newConfig.kis.account_prod = document.getElementById('account_prod').value;
newConfig.kis.htsid = document.getElementById('htsid').value;
newConfig.kis.is_paper = document.getElementById('is_paper').value === "true";
if (!newConfig.preferences) newConfig.preferences = {};
newConfig.preferences.enable_news = document.getElementById('enable_news').checked;
newConfig.preferences.enable_telegram = document.getElementById('enable_telegram').checked;
if (!newConfig.naver) newConfig.naver = {};
newConfig.naver.client_id = document.getElementById('naver_id').value;
newConfig.naver.client_secret = document.getElementById('naver_secret').value;
if (!newConfig.google) newConfig.google = {};
newConfig.google.api_key = document.getElementById('google_key').value;
if (!newConfig.telegram) newConfig.telegram = {};
newConfig.telegram.bot_token = document.getElementById('bot_token').value;
newConfig.telegram.chat_id = document.getElementById('chat_id').value;
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(newConfig)
});
if (res.ok) alert('설정이 저장되었습니다. 서버를 재시작해야 적용될 수 있습니다.');
else alert('저장 실패');
} catch(e) { console.error(e); alert('Error'); }
}
loadSettings();
</script>
</body>
</html>

180
frontend/stocks.html Normal file
View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>종목 정보 - KisStock AI</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav>
<div class="logo">KisStock AI</div>
<div class="nav-links">
<a href="/">대시보드</a>
<a href="/trade">매매</a>
<a href="/news">AI뉴스</a>
<a href="/stocks" class="active">종목</a>
<a href="/settings">설정</a>
</div>
</nav>
<div class="container">
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<h2>📦 전체 종목 DB</h2>
<div>
<button class="btn" onclick="syncMaster()" style="background:#475569; font-size:0.9rem;">🔄 DB 동기화 (최신 데이터 수집)</button>
</div>
</div>
<div class="grid" style="grid-template-columns: 1fr 2fr 1fr; gap:1rem; margin-bottom:1rem;">
<select id="market-filter" onchange="searchStocks()">
<option value="">전체 시장</option>
<option value="KOSPI">KOSPI</option>
<option value="KOSDAQ">KOSDAQ</option>
<option value="NASD">NASDAQ</option>
<option value="NYSE">NYSE</option>
<option value="AMEX">AMEX</option>
</select>
<input type="text" id="search-keyword" placeholder="종목명 또는 코드 검색 (Enter)" onkeypress="if(event.key==='Enter') searchStocks()">
<button class="btn btn-primary" onclick="searchStocks()">검색</button>
</div>
<div style="overflow-x:auto;">
<table id="stocks-table">
<thead>
<tr>
<th>시장</th>
<th>코드</th>
<th>종목명</th>
<th>관리</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="pagination" style="margin-top:1rem; text-align:center;"></div>
</div>
</div>
<script>
let currentPage = 1;
async function searchStocks(page=1) {
currentPage = page;
const keyword = document.getElementById('search-keyword').value;
const market = document.getElementById('market-filter').value;
try {
const res = await fetch(`/api/stocks?keyword=${encodeURIComponent(keyword)}&market=${market}&page=${page}`);
const data = await res.json();
const tbody = document.querySelector('#stocks-table tbody');
tbody.innerHTML = '';
data.items.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span class="badge ${item.type === 'DOMESTIC' ? 'text-success' : 'text-danger'}">${item.market}</span></td>
<td>${item.code}</td>
<td>${item.name}</td>
<td>
<button class="btn" style="padding:0.2rem 0.5rem; font-size:0.8rem;" onclick="addToWatchlist('${item.code}', '${item.name}', '${item.market}')">⭐ 관심등록</button>
</td>
`;
tbody.appendChild(tr);
});
// Pagination Logic (Simple)
// Assuming API returns total or we just simpler Next/Prev for now
document.getElementById('pagination').innerHTML = `
<button class="btn" style="width:auto; display:inline-block;" onclick="searchStocks(${page-1})" ${page<=1?'disabled':''}>이전</button>
<span style="margin:0 1rem;">Page ${page}</span>
<button class="btn" style="width:auto; display:inline-block;" onclick="searchStocks(${page+1})">다음</button>
`;
} catch(e) { console.error(e); }
}
async function addToWatchlist(code, name, market) {
try {
const res = await fetch('/api/watchlist', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code, name, market })
});
if(res.ok) alert("관심종목에 추가되었습니다.");
else alert("이미 존재하거나 오류가 발생했습니다.");
} catch(e) { alert("Error: " + e.message); }
}
async function syncMaster() {
if(!confirm("전체 종목 데이터를 서버로 다운로드하고 동기화합니다.\n시간이 소요될 수 있습니다. 진행하시겠습니까?")) return;
try {
const res = await fetch('/api/sync/master', { method: 'POST' });
if(res.ok) {
alert("동기화 작업이 백그라운드에서 시작되었습니다. 상태를 모니터링합니다.");
pollSyncStatus();
}
} catch(e) { alert("요청 실패"); }
}
async function pollSyncStatus() {
const statusDiv = document.getElementById('sync-status-display') || createStatusDiv();
const btn = document.querySelector('button[onclick="syncMaster()"]');
// Poll every 2s
const interval = setInterval(async () => {
try {
const res = await fetch('/api/sync/status');
const data = await res.json();
statusDiv.innerHTML = `STATUS: ${data.status.toUpperCase()} - ${data.message}`;
if (data.status === 'running') {
btn.disabled = true;
btn.innerText = "⏳ 동기화 중...";
statusDiv.className = "alert alert-info";
} else if (data.status === 'error') {
btn.disabled = false;
btn.innerText = "🔄 DB 동기화 (재시도)";
statusDiv.className = "alert alert-danger";
clearInterval(interval);
alert("동기화 중 오류가 발생했습니다: " + data.message);
} else if (data.status === 'done') {
btn.disabled = false;
btn.innerText = "🔄 DB 동기화 (완료)";
statusDiv.className = "alert alert-success";
clearInterval(interval);
alert("동기화 완료!");
searchStocks(); // Refresh list
} else if (data.status === 'warning') {
btn.disabled = false;
btn.innerText = "🔄 DB 동기화 (부분완료)";
statusDiv.className = "alert alert-warning";
clearInterval(interval);
alert("부분 완료 (일부 실패): " + data.message);
}
} catch(e) { clearInterval(interval); }
}, 2000);
}
function createStatusDiv() {
const div = document.createElement('div');
div.id = 'sync-status-display';
div.style.padding = "10px";
div.style.marginBottom = "10px";
div.style.borderRadius = "4px";
div.style.fontSize = "0.9rem";
// Insert before grid
const container = document.querySelector('.card');
container.insertBefore(div, container.children[1]);
return div;
}
// Init load
searchStocks();
</script>
</body>
</html>

377
frontend/trade.html Normal file
View File

@@ -0,0 +1,377 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>매매 - KisStock AI</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.tab-menu {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.tab-btn {
background: none;
border: none;
color: var(--text-secondary);
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 1rem;
border-bottom: 2px solid transparent;
}
.tab-btn.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
font-weight: bold;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
input, select {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text-primary);
border-radius: 4px;
}
.btn {
width: 100%;
padding: 0.8rem;
border: none;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
margin-top: 0.5rem;
}
.btn-buy { background: #ef4444; color: white; }
.btn-sell { background: #3b82f6; color: white; }
.btn-cancel { background: #f59e0b; color: white; padding: 0.4rem; font-size: 0.8rem; width: auto;}
</style>
</head>
<body>
<nav>
<div class="logo">KisStock AI</div>
<div class="nav-links">
<a href="/">대시보드</a>
<a href="/trade" class="active">매매</a>
<a href="/settings">설정</a>
</div>
</nav>
<div class="container">
<div class="card">
<div class="tab-menu">
<button class="tab-btn active" onclick="showTab('order')">주문하기</button>
<button class="tab-btn" onclick="showTab('cancel')">정정/취소</button>
<button class="tab-btn" onclick="showTab('history')">체결내역</button>
</div>
<!-- Tab 1: Order -->
<div id="tab-order" class="tab-content active">
<h2>⚡ 간편 매매</h2>
<div class="grid" style="grid-template-columns: 1fr 1fr; gap: 2rem;">
<div>
<label>종목코드</label>
<input type="text" id="code" placeholder="예: 005930">
<button class="btn" style="background:var(--card-bg); border:1px solid #fff;" onclick="searchStock()">현재가 조회</button>
<div id="price-display" style="margin-top:1rem; font-size:1.2rem;"></div>
<hr style="margin: 1rem 0; border-color: rgba(255,255,255,0.1);">
<h3>🤖 자동매매 감시</h3>
<div class="form-group">
<label>목표가 (익절)</label>
<input type="number" id="target-price" placeholder="0">
</div>
<div class="form-group">
<label>손절가 (로스컷)</label>
<input type="number" id="loss-price" placeholder="0">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="auto-active" style="width:auto;"> 감시 활성화
</label>
</div>
<button class="btn" style="background:var(--accent-color); color:white;" onclick="saveAutoSettings()">감시 설정 저장</button>
</div>
<div>
<label>수량</label>
<input type="number" id="qty" value="1">
<label>가격 (0 = 시장가)</label>
<input type="number" id="price" value="0">
<div class="grid" style="grid-template-columns: 1fr 1fr; gap: 1rem;">
<button class="btn btn-buy" onclick="placeOrder('buy')">매수</button>
<button class="btn btn-sell" onclick="placeOrder('sell')">매도</button>
</div>
</div>
</div>
</div>
<!-- Tab 2: Cancel -->
<div id="tab-cancel" class="tab-content">
<h2>♻ 미체결/취소 가능 주문</h2>
<button class="btn" onclick="fetchCancelable()" style="background:#475569; margin-bottom:1rem;">목록 갱신</button>
<div style="overflow-x:auto;">
<table id="cancel-table" style="width:100%; text-align:left;">
<thead>
<tr>
<th>주문번호</th>
<th>종목</th>
<th>구분</th>
<th>수량/잔량</th>
<th>가격</th>
<th>관리</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- Tab 3: History -->
<div id="tab-history" class="tab-content">
<h2>📜 금일 체결 내역</h2>
<button class="btn" onclick="fetchHistory()" style="background:#475569; margin-bottom:1rem;">내역 조회</button>
<div style="overflow-x:auto;">
<table id="history-table" style="width:100%; text-align:left;">
<thead>
<tr>
<th>시간</th>
<th>주문번호</th>
<th>종목</th>
<th>구분</th>
<th>체결수량</th>
<th>체결단가</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Init: Parse URL params
window.onload = function() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const market = params.get('market');
const type = params.get('type');
if(code) {
document.getElementById('code').value = code;
searchStock(code); // Auto-load price
}
// Optional: Handle 'market' if we had a market selector in trade page (we might need one for overseas order API)
// Optional: Handle 'type' (buy/sell) - could switch button focus or auto-prepare?
// For now, user clicks buttons manually, but we could highlight.
if(type) {
// visually highlight button?
}
}
// Tab switching
function showTab(name) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById('tab-'+name).classList.add('active');
// Find button logic if needed, but simple onclick works for now
// We need to match the button that called this.
// Better to iterate buttons and regex or id match?
// Let's just reset buttons manually or pass 'this'
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => {
if(btn.innerText.includes(name === 'order' ? '주문' : (name === 'cancel' ? '취소' : '체결'))) {
btn.classList.add('active');
}
});
if(name === 'cancel') fetchCancelable();
if(name === 'history') fetchHistory();
}
async function searchStock(paramCode) {
const code = paramCode || document.getElementById('code').value;
if(!code) return;
try {
const res = await fetch(`/api/price/${code}`);
const data = await res.json();
document.getElementById('price-display').innerText =
`${data.bstp_kor_isnm || ''} 현재가: ${parseInt(data.stck_prpr || 0).toLocaleString()}`;
// Also load auto settings
loadAutoSettings(code);
} catch(e) { /* Ignore or alert */ }
}
async function placeOrder(type) {
const code = document.getElementById('code').value;
const qty = document.getElementById('qty').value;
const price = document.getElementById('price').value;
// Need Market info?
// 1. Get from URL if present
const params = new URLSearchParams(window.location.search);
let market = params.get('market') || "DOMESTIC";
// Note: If user typed code manually, we default to DOMESTIC logic unless we fetch info.
if(!code || !qty) return alert("입력값을 확인하세요");
if(!confirm(`${type === 'buy' ? '매수' : '매도'} 주문을 전송하시겠습니까?`)) return;
try {
const res = await fetch('/api/order', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code, type, qty: parseInt(qty), price: parseInt(price), market })
});
const data = await res.json();
if(data.rt_cd === '0') {
alert("주문 전송 성공: " + data.msg1);
} else {
alert("주문 실패: " + data.msg1);
}
} catch(e) { alert("Error: " + e.message); }
}
async function fetchCancelable() {
try {
const res = await fetch('/api/orders/cancelable');
const data = await res.json();
const tbody = document.querySelector('#cancel-table tbody');
tbody.innerHTML = '';
if(!data.output) {
tbody.innerHTML = '<tr><td colspan="6">데이터가 없거나 조회 실패</td></tr>';
return;
}
data.output.forEach(item => {
// Only show if psbl_qty > 0 (Cancelable)
if (parseInt(item.psbl_qty) <= 0) return;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.odno}</td>
<td>${item.prdt_name} (${item.pdno})</td>
<td>${item.sll_buy_dvsn_cd_name}</td>
<td>${item.ord_qty} / <span style="color:#f59e0b">${item.psbl_qty}</span></td>
<td>${parseInt(item.ord_unpr).toLocaleString()}</td>
<td>
<button class="btn btn-cancel" onclick="cancelOrder('${item.krx_fwdg_ord_orgno}', '${item.odno}', ${item.psbl_qty}, '${item.pdno}')">취소</button>
</td>
`;
tbody.appendChild(tr);
});
if(tbody.children.length === 0) {
tbody.innerHTML = '<tr><td colspan="6">취소 가능한 주문이 없습니다.</td></tr>';
}
} catch(e) { console.error(e); }
}
async function cancelOrder(orgNo, orderNo, qty, code) {
if(!confirm(`주문번호 ${orderNo} (${qty}주)를 취소하시겠습니까?`)) return;
try {
const res = await fetch('/api/order/cancel', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
org_no: orgNo,
order_no: orderNo,
qty: parseInt(qty),
is_buy: true,
price: 0
})
});
const data = await res.json();
alert(data.msg1);
fetchCancelable();
} catch(e) { alert(e.message); }
}
async function fetchHistory() {
try {
const res = await fetch('/api/orders/daily');
const data = await res.json();
const tbody = document.querySelector('#history-table tbody');
tbody.innerHTML = '';
if(!data.output1) {
tbody.innerHTML = '<tr><td colspan="6">데이터가 없거나 조회 실패</td></tr>';
return;
}
data.output1.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.ord_dt} ${item.ord_tmd}</td>
<td>${item.odno}</td>
<td>${item.prdt_name}</td>
<td>${item.sll_buy_dvsn_cd_name}</td>
<td>${item.tot_ccld_qty} / ${item.ord_qty}</td>
<td>${parseInt(item.avg_prvs).toLocaleString()}</td>
`;
tbody.appendChild(tr);
});
} catch(e) { console.error(e); }
}
async function loadAutoSettings(code) {
try {
const res = await fetch('/api/trade_settings');
const data = await res.json();
const setting = data.find(s => s.code === code);
if (setting) {
document.getElementById('target-price').value = setting.target_price || '';
document.getElementById('loss-price').value = setting.stop_loss_price || '';
document.getElementById('auto-active').checked = setting.is_active;
} else {
document.getElementById('target-price').value = '';
document.getElementById('loss-price').value = '';
document.getElementById('auto-active').checked = false;
}
} catch (e) { console.error(e); }
}
async function saveAutoSettings() {
const code = document.getElementById('code').value;
if (!code) return alert('종목 코드 입력 필요');
const target = document.getElementById('target-price').value;
const loss = document.getElementById('loss-price').value;
const active = document.getElementById('auto-active').checked;
try {
const res = await fetch('/api/trade_settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
code: code,
target_price: target ? parseFloat(target) : null,
stop_loss_price: loss ? parseFloat(loss) : null,
is_active: active
})
});
if(res.ok) alert('감시 설정 저장 완료');
} catch(e) { alert("저장 실패"); }
}
</script>
</body>
</html>

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi
uvicorn
sqlalchemy
requests
pandas
pyyaml
jinja2
python-multipart
websockets
python-telegram-bot
watchfiles
google-generativeai

26
run.bat Normal file
View File

@@ -0,0 +1,26 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo ========================================================
echo KisStock AI 주식 자동매매 시스템을 시작합니다.
echo ========================================================
:: Check Python
python --version > nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Python이 설치되어 있지 않거나 PATH에 설정되지 않았습니다.
pause
exit /b
)
echo.
echo [INFO] 브라우저를 실행합니다...
:: Start browser after 2 seconds (waiting for server to start)
start /b "" cmd /c "timeout /t 2 > nul & start http://localhost:8000"
echo [INFO] 서버를 시작합니다... (종료하려면 Ctrl+C를 누르세요)
echo.
python backend/main.py
pause

48
test_migration.py Normal file
View File

@@ -0,0 +1,48 @@
from backend.database import init_db, SessionLocal, AccountBalance, Holding, engine
from backend.trader import trader
import logging
logging.basicConfig(level=logging.INFO)
def test_db_migration():
print("Initializing DB...")
init_db()
# Check tables
from sqlalchemy import inspect
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"Tables: {tables}")
if "account_balance" in tables and "holdings" in tables:
print("PASS: New tables created.")
else:
print("FAIL: Tables missing.")
def test_sync():
print("Testing Asset Sync (Mocking KIS)...")
# Note: KIS API might fail if credentials are invalid or market is closed/paper env issues.
# We will try running it and catch errors.
try:
trader.refresh_assets()
print("Sync function executed.")
db = SessionLocal()
acc = db.query(AccountBalance).first()
holdings = db.query(Holding).all()
if acc:
print(f"Account Balance: Eval={acc.total_eval}, Deposit={acc.deposit}")
else:
print("Account Balance: None (Sync might have failed or empty)")
print(f"Holdings Count: {len(holdings)}")
db.close()
except Exception as e:
print(f"Sync Logic Error: {e}")
if __name__ == "__main__":
test_db_migration()
# test_sync() # Skip actual sync test to avoid making real API calls during verification step unless user wants.
# Actually user wants "Proceed", so let's try to verify DB schema at least.

14
목표.md Normal file
View File

@@ -0,0 +1,14 @@
한국투자증권 api 소스파일을 넣어뒀어 그거 참고해서..
백엔드와 프론트를 만들어줘, 주식 자동매매 사이트이고.. 프론트말고 백엔드만으로도 주식 매매는 진행되어야하고. 프론트는 셋팅을 하는 그런용도야.
api 키와 비밀키는 셋팅파일을 읽어서 처리해주고. 셋팅만들어주면 내가 키입력을 할게.
관심종목도 등록할 수 있어야하고,, 당연히 계좌정보 조회 및 현재 체결 미체결 재고모두 보이고.
매도, 매수기능 있어야하고
종목클릭해서 호가 및 차트 정보도 나와야해. (물론 그 화면에서 매도 매수 가능해야지)
매도예약기능도 있어야하고, 특정 조건에 맞으면 매도가 되는거지.. TS 매도,,
매수예약기능도 마찬가지야. TS조건포함해서.
모바일에서 잘 표시되도록 해주고.
업종목록을 보는것도 있어야하고, 업종에 대한 health 체크도 해야하니. 업종을 분석한 결과를 저장할 수도 있어야해. 업종 분석은 ai를 사용할건데. 구글 api를 기본 연결해서 진행하게 해줘.
뉴스도 가져와서 업종별로 보여주고, 뉴스에 대한 분석도 ai를 사용해서 보여주면 좋겠어.(뉴스는 네이버 API를 제공해줄게)
뉴스는 기본 10분간격으로 최신 뉴스를 가져오고, 최신 뉴스에서 주식과 관련이 있는지를 우선 빠르게 판단하고 그 이후에 추가분석을 해서 관련 업종 및 테마를 분류해서 영향력을 계산해야. 뉴스 분석 등은 별도의 쓰레드에서 해야해
데이터베이슨은 SQLLITE 를 사용하고 다중접속에 덜 취약한 셋팅을 해줘.
업종에는 해당 업종의 health 정보도 저장되어있어야하고 어떤테마에 속해있는지도 저장되어있어야해.

167
한국투자증권(API)/.gitignore vendored Normal file
View File

@@ -0,0 +1,167 @@
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
*.lock
*_logs/

View File

@@ -0,0 +1,77 @@
# Git 관련
.git
.gitignore
# Python 관련
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
# 개발 환경
.venv
venv/
ENV/
env/
.env
.env.*
!.env.live
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS 관련
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# 프로젝트 특정
tmp/
logs/
*.tmp
*.log
# Docker 관련
Dockerfile
.dockerignore
docker-compose*.yml
# 문서
*.md
!README.md
# 테스트
test_*.py
*_test.py
tests/
# 기타
.env.example
env.example
*.csv
*.tmp

View File

@@ -0,0 +1,4 @@
MCP_TYPE=sse
MCP_HOST=0.0.0.0
MCP_PORT=3000
MCP_PATH=/sse

View File

@@ -0,0 +1,23 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.idea
.vscode
*.log
*_log/
*_logs/
tmp/
*.csv
!standalone_util/*.csv
*.tmp
*.db

View File

@@ -0,0 +1 @@
3.13

View File

@@ -0,0 +1,63 @@
# Python 3.13 slim 이미지 사용
FROM python:3.13-slim
# 작업 디렉토리 설정
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치를 위한 uv 설치
RUN pip install uv
# pyproject.toml 복사 (uv.lock이 없을 수 있으므로)
COPY pyproject.toml ./
# uv.lock이 있으면 복사, 없으면 의존성만 설치
COPY uv.lock* ./
# 의존성 설치 (uv.lock이 있으면 frozen, 없으면 일반 설치)
RUN if [ -f uv.lock ]; then uv sync --frozen; else uv sync; fi
# 애플리케이션 코드 복사
COPY . .
# 환경변수 설정
ENV ENV=live
ENV PYTHONPATH=/app
# 포트 노출 (HTTP 서버용)
EXPOSE 3000
# 환경변수 정의 (런타임에 설정됨)
ENV KIS_APP_KEY=""
ENV KIS_APP_SECRET=""
ENV KIS_PAPER_APP_KEY=""
ENV KIS_PAPER_APP_SECRET=""
ENV KIS_HTS_ID=""
ENV KIS_ACCT_STOCK=""
ENV KIS_ACCT_FUTURE=""
ENV KIS_PAPER_STOCK=""
ENV KIS_PAPER_FUTURE=""
ENV KIS_PROD_TYPE=""
ENV KIS_URL_REST=""
ENV KIS_URL_REST_PAPER=""
ENV KIS_URL_WS=""
ENV KIS_URL_WS_PAPER=""
# 시작 스크립트 생성
RUN echo '#!/bin/bash\n\
set -e\n\
\n\
echo "Starting KIS Trade MCP Server..."\n\
echo "Environment: $ENV"\n\
\n\
# MCP 서버 시작 (HTTP 모드)\n\
exec uv run python server.py\n\
' > /app/start.sh && chmod +x /app/start.sh
# 시작 스크립트 실행
CMD ["/app/start.sh"]

View File

@@ -0,0 +1,391 @@
# 중요 : MCP에 대한 내용을 완전히 숙지하신 뒤 사용해 주십시오.
# 이 프로그램을 실행하여 발생한 모든 책임은 사용자 본인에게 있습니다.
# 한국투자증권 OPEN API MCP 서버 - Docker 설치 가이드
한국투자증권의 다양한 금융 API를 Docker를 통해 Claude Desktop에서 쉽게 사용할 수 있도록 하는 설치 가이드입니다.
## 🚀 주요 기능
### 지원하는 API 카테고리
| 카테고리 | 개수 | 주요 기능 |
|---------|------|----------|
| 국내주식 | 74개 | 현재가, 호가, 차트, 잔고, 주문, 순위분석, 시세분석, 종목정보, 실시간시세 등 |
| 해외주식 | 34개 | 미국/아시아 주식 시세, 잔고, 주문, 체결내역, 거래량순위, 권리종합 등 |
| 국내선물옵션 | 20개 | 선물옵션 시세, 호가, 차트, 잔고, 주문, 야간거래, 실시간체결 등 |
| 해외선물옵션 | 19개 | 해외선물 시세, 주문내역, 증거금, 체결추이, 옵션호가 등 |
| 국내채권 | 14개 | 채권 시세, 호가, 발행정보, 잔고조회, 주문체결내역 등 |
| ETF/ETN | 2개 | NAV 비교추이, 현재가 등 |
| ELW | 1개 | ELW 거래량순위 |
**전체 API 총합계: 166개**
### 핵심 특징
- 🐳 **Docker 컨테이너화**: 완전 격리된 환경에서 안전한 실행
-**동적 코드 실행**: GitHub에서 실시간으로 API 코드를 다운로드하여 실행
- 🔧 **설정 기반**: JSON 파일로 API 설정 및 파라미터 관리
- 🛡️ **안전한 실행**: 격리된 임시 환경에서 코드 실행
- 🔍 **검증 기능**: API 상세 정보 조회로 파라미터 확인
- 🌍 **환경 지원**: 실전/모의 환경 구분 지원
- 🔐 **자동 설정**: 서버 시작 시 KIS 인증 설정 자동 생성
- 🖥️ **크로스 플랫폼**: Windows, macOS, Linux 모두 지원
## 📦 Docker 설치 및 설정
### 📋 Docker 설치
#### 🚀 빠른 설치 (권장)
**공식 Docker Desktop을 사용하세요:**
- [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop/)
- [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/)
- [Docker Engine for Linux](https://docs.docker.com/engine/install/)
#### 📋 OS별 간단 가이드
##### 🍎 **macOS**
```bash
# Homebrew 사용 (권장)
brew install --cask docker
# 또는 공식 인스톨러 다운로드
# https://www.docker.com/products/docker-desktop/
```
##### 🐧 **Linux (Ubuntu/Debian)**
```bash
# 공식 스크립트 사용 (권장)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# 사용자를 docker 그룹에 추가
sudo usermod -aG docker $USER
```
##### 🪟 **Windows**
**⚠️ Windows는 추가 설정이 필요합니다:**
1. **시스템 요구사항 확인**
- Windows 10/11 Pro, Enterprise, Education
- WSL2 또는 Hyper-V 지원
2. **Docker Desktop 설치**
- [공식 사이트](https://www.docker.com/products/docker-desktop/)에서 다운로드
- 설치 중 "Use WSL 2" 옵션 선택 권장
3. **설치 후 확인**
```cmd
docker --version
docker run hello-world
```
**Windows 상세 설치 가이드**: [Docker 공식 문서](https://docs.docker.com/desktop/install/windows-install/) 참조
### 요구사항
- Docker 20.10+
- 한국투자증권 OPEN API 계정
### 📋 설치 및 설정 단계
#### **1단계: 프로젝트 클론**
```bash
# 프로젝트 클론
git clone https://github.com/koreainvestment/open-trading-api.git
cd "open-trading-api/MCP/Kis Trading MCP"
```
#### **2단계: 한국투자증권 API 정보 준비**
한국투자증권 개발자 센터에서 발급받은 정보를 준비하세요:
**필수 정보:**
- App Key (실전용)
- App Secret (실전용)
- 계좌 정보들
**선택 정보:**
- App Key (모의용)
- App Secret (모의용)
#### **3단계: Docker 이미지 빌드**
```bash
# Docker 이미지 빌드
docker build -t kis-trade-mcp .
# 또는 태그와 함께 빌드
docker build -t kis-trade-mcp:latest .
```
#### **4단계: Docker 컨테이너 실행**
**기본 실행:**
```bash
docker run -d \
--name kis-trade-mcp \
-p 3000:3000 \
-e KIS_APP_KEY="your_app_key" \
-e KIS_APP_SECRET="your_app_secret" \
-e KIS_PAPER_APP_KEY="your_paper_app_key" \
-e KIS_PAPER_APP_SECRET="your_paper_app_secret" \
-e KIS_HTS_ID="your_hts_id" \
-e KIS_ACCT_STOCK="12345678" \
-e KIS_ACCT_FUTURE="87654321" \
-e KIS_PAPER_STOCK="11111111" \
-e KIS_PAPER_FUTURE="22222222" \
-e KIS_PROD_TYPE="01" \
kis-trade-mcp
```
#### **5단계: 컨테이너 상태 확인**
```bash
# 컨테이너 상태 확인
docker ps
# 컨테이너 로그 확인
docker logs kis-trade-mcp
# 실시간 로그 확인
docker logs -f kis-trade-mcp
# HTTP 서버 접근 확인
curl http://localhost:3000/sse
```
#### **6단계: HTTP 서버 접근 확인**
컨테이너가 정상적으로 실행되면 HTTP 서버에 접근할 수 있습니다:
```bash
# 서버 상태 확인
curl http://localhost:3000/sse
# 또는 브라우저에서 접근
# http://localhost:3000/sse
```
### 🔗 Claude Desktop 연동 및 설정
#### 📝 Claude Desktop 설정
Claude Desktop 설정 파일에 MCP 서버를 등록하세요.
**설정 파일 위치:**
- **Linux/Mac**: `~/.claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
#### 🐧 Linux/Mac 설정
```json
{
"mcpServers": {
"kis-trade-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/sse"]
}
}
}
```
#### 🪟 Windows 설정
```json
{
"mcpServers": {
"kis-trade-mcp": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/sse"]
}
}
}
```
## 💬 사용법 및 질문 예시
### 기본 사용 패턴
1. **종목 검색**: 먼저 종목 코드를 찾습니다
2. **API 확인**: 사용할 API의 파라미터를 확인합니다
3. **API 호출**: 필요한 파라미터와 함께 API를 호출합니다
### 질문 예시
**주식 시세 조회:**
- "삼성전자(005930) 현재가 시세 조회해줘"
- "애플(AAPL) 해외주식 현재 체결가 알려줘"
- "삼성전자 종목코드 찾아줘"
**잔고 및 계좌:**
- "국내주식 잔고 조회해줘"
- "해외주식 잔고 확인해줘"
**채권 및 기타:**
- "국고채 3년물 호가 정보 조회하는 방법"
- "KODEX 200 ETF(069500) NAV 비교추이 확인해줘"
**모의투자:**
- "모의투자로 삼성전자 현재가 조회해줘"
- "데모 환경에서 애플 주식 시세 알려줘"
## 🔧 컨테이너 관리
### 컨테이너 제어
```bash
# 컨테이너 시작
docker start kis-trade-mcp
# 컨테이너 중지
docker stop kis-trade-mcp
# 컨테이너 재시작
docker restart kis-trade-mcp
# 컨테이너 제거
docker stop kis-trade-mcp
docker rm kis-trade-mcp
```
### 컨테이너 내부 접근
```bash
# 컨테이너 내부 bash 실행
docker exec -it kis-trade-mcp /bin/bash
# 환경변수 확인
docker exec kis-trade-mcp env | grep KIS
# 로그 실시간 확인
docker logs -f kis-trade-mcp
```
## 💡 사용 팁
1. **환경변수 관리**: 민감한 정보는 환경변수로 안전하게 관리
2. **로그 모니터링**: `docker logs -f`로 실시간 로그 확인
3. **리소스 모니터링**: `docker stats`로 컨테이너 리소스 사용량 확인
4. **백업 전략**: 중요한 설정 파일은 정기적으로 백업
5. **보안 관리**: 컨테이너 내부에서만 민감한 정보 처리
## 📝 로깅 및 모니터링
### 로그 확인
```bash
# 전체 로그
docker logs kis-trade-mcp
# 최근 100줄
docker logs --tail 100 kis-trade-mcp
# 실시간 로그
docker logs -f kis-trade-mcp
# 특정 시간대 로그
docker logs --since "2024-01-01T00:00:00" kis-trade-mcp
```
### 성능 모니터링
```bash
# 컨테이너 리소스 사용량
docker stats kis-trade-mcp
# 컨테이너 상세 정보
docker inspect kis-trade-mcp
# 프로세스 확인
docker exec kis-trade-mcp ps aux
```
## 🛠️ 문제 해결
### 일반적인 문제들
**1. 컨테이너가 시작되지 않는 경우**
```bash
# 로그 확인
docker logs kis-trade-mcp
# 환경변수 확인
docker exec kis-trade-mcp env | grep KIS
```
**2. 환경변수 누락**
```bash
# 컨테이너 재시작
docker restart kis-trade-mcp
# 환경변수 다시 설정하여 실행
docker run -d --name kis-trade-mcp -e KIS_APP_KEY="..." ...
```
**3. 메모리 부족**
```bash
# 메모리 사용량 확인
docker stats kis-trade-mcp
# 컨테이너 리소스 제한 설정
docker run -d --name kis-trade-mcp --memory="2g" --cpus="2" ...
```
**4. 네트워크 연결 문제**
```bash
# 포트 확인
docker port kis-trade-mcp
# 네트워크 연결 테스트
curl http://localhost:3000/sse
```
### 디버깅 명령어
```bash
# 컨테이너 내부 bash 접근
docker exec -it kis-trade-mcp /bin/bash
# Python 환경 확인
docker exec kis-trade-mcp uv run python -c "import sys; print(sys.path)"
# 의존성 확인
docker exec kis-trade-mcp uv pip list
# 네트워크 연결 확인
docker exec kis-trade-mcp ping github.com
```
## 🔒 보안 고려사항
- **컨테이너 격리**: 호스트 시스템과 완전히 분리된 환경에서 실행
- **환경변수 보안**: 민감한 정보는 환경변수로 전달, 코드에 하드코딩 금지
- **임시 파일 정리**: 각 API 호출 후 임시 파일 자동 삭제
- **네트워크 격리**: 필요한 경우 Docker 네트워크를 통한 추가 격리
## ⚠️ 제한사항 및 성능
### API 호출 제한
- 한국투자증권 API의 호출 제한을 준수해야 합니다
- 분당 호출 횟수 제한이 있을 수 있습니다
- 실전 환경에서는 더욱 신중한 사용이 필요합니다
### Docker 성능 고려사항
- **컨테이너 오버헤드**: Docker 컨테이너 실행으로 인한 약간의 성능 오버헤드
- **메모리 사용량**: SQLAlchemy와 pandas가 메모리를 많이 사용할 수 있음
- **네트워크 지연**: GitHub 다운로드 시 네트워크 지연 발생
### 다단계 타임아웃 설정
- 파일 다운로드: 30초 (GitHub 응답 대기)
- 코드 실행: 15초 (API 호출 및 결과 처리)
- 컨테이너 시작: 60초 (의존성 설치 및 초기화)
## 🔗 관련 링크
- [한국투자증권 개발자 센터](https://apiportal.koreainvestment.com/)
- [한국투자증권 OPEN API GitHub](https://github.com/koreainvestment/open-trading-api)
- [MCP (Model Context Protocol) 공식 문서](https://modelcontextprotocol.io/)
- [Docker 공식 문서](https://docs.docker.com/)
---
**주의**: 이 프로젝트는 한국투자증권 OPEN API를 사용합니다. 사용 전 반드시 [한국투자증권 개발자 센터](https://apiportal.koreainvestment.com/)에서 API 이용약관을 확인하시기 바랍니다.
## ⚠️ 투자 책임 고지
**본 MCP 서버는 한국투자증권 OPEN API를 활용한 도구일 뿐이며, 투자 조언이나 권유를 제공하지 않습니다.**
- 📈 **투자 결정 책임**: 모든 투자 결정과 그에 따른 손익은 전적으로 투자자 본인의 책임입니다
- 💰 **손실 위험**: 주식, 선물, 옵션 등 모든 금융상품 투자에는 원금 손실 위험이 있습니다
- 🔍 **정보 검증**: API를 통해 제공되는 정보의 정확성은 한국투자증권에 의존하며, 투자 전 반드시 정보를 검증하시기 바랍니다
- 🧠 **신중한 판단**: 충분한 조사와 신중한 판단 없이 투자하지 마시기 바랍니다
- 🎯 **모의투자 권장**: 실전 투자 전 반드시 모의투자를 통해 충분히 연습하시기 바랍니다
**투자는 본인의 판단과 책임 하에 이루어져야 하며, 본 도구 사용으로 인한 어떠한 손실에 대해서도 개발자는 책임지지 않습니다.**

View File

@@ -0,0 +1,105 @@
{
"tool_info": {
"introduce": "한국투자증권의 auth OPEN API를 활용합니다.",
"introduce_append": "",
"examples": [
{
"api_type": "auth_token",
"params": {
"grant_type": "client_credentials",
"env_dv": "real"
}
},
{
"api_type": "auth_ws_token",
"params": {
"grant_type": "client_credentials",
"env_dv": "real"
}
}
]
},
"apis": {
"auth_token": {
"category": "OAuth인증",
"name": "접근토큰발급(P)",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/auth/auth_token",
"method": "auth_token",
"api_path": "/oauth2/tokenP",
"params": {
"grant_type": {
"name": "grant_type",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 권한부여 Type (client_credentials)"
},
"appkey": {
"name": "appkey",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 앱키 (한국투자증권 홈페이지에서 발급받은 appkey)"
},
"appsecret": {
"name": "appsecret",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 앱시크릿키 (한국투자증권 홈페이지에서 발급받은 appsecret)"
},
"env_dv": {
"name": "env_dv",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 환경구분 (real: 실전, demo: 모의)"
}
}
},
"auth_ws_token": {
"category": "OAuth인증",
"name": "실시간 (웹소켓) 접속키 발급",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/auth/auth_ws_token",
"method": "auth_ws_token",
"api_path": "/oauth2/Approval",
"params": {
"grant_type": {
"name": "grant_type",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 권한부여 Type (client_credentials)"
},
"appkey": {
"name": "appkey",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 고객 앱Key (한국투자증권 홈페이지에서 발급받은 appkey)"
},
"appsecret": {
"name": "appsecret",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 고객 앱Secret (한국투자증권 홈페이지에서 발급받은 appsecret)"
},
"env_dv": {
"name": "env_dv",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 환경구분 (real: 실전, demo: 모의)"
},
"token": {
"name": "token",
"type": "str",
"required": false,
"default_value": "",
"description": "접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)"
}
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
{
"tool_info": {
"introduce": "한국투자증권의 ELW OPEN API를 활용합니다.",
"introduce_append": "이 도구는 ELW 관련 시세 정보를 제공합니다.",
"examples": [
{
"api_type": "volume_rank",
"params": {
"fid_cond_mrkt_div_code": "W",
"fid_cond_scr_div_code": "20278",
"fid_unas_input_iscd": "000000",
"fid_input_iscd": "00000",
"fid_input_rmnn_dynu_1": "",
"fid_div_cls_code": "0",
"fid_input_price_1": "1000",
"fid_input_price_2": "5000",
"fid_input_vol_1": "100",
"fid_input_vol_2": "1000",
"fid_input_date_1": "20230101",
"fid_rank_sort_cls_code": "0",
"fid_blng_cls_code": "0",
"fid_input_iscd_2": "0000",
"fid_input_date_2": ""
}
}
]
},
"apis": {
"volume_rank": {
"category": "[국내주식] ELW시세",
"name": "ELW 거래량순위",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/elw/volume_rank",
"method": "volume_rank",
"api_path": "/uapi/elw/v1/ranking/volume-rank",
"params": {
"fid_cond_mrkt_div_code": {
"name": "fid_cond_mrkt_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "조건시장분류코드"
},
"fid_cond_scr_div_code": {
"name": "fid_cond_scr_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "조건화면분류코드"
},
"fid_unas_input_iscd": {
"name": "fid_unas_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "기초자산입력종목코드"
},
"fid_input_iscd": {
"name": "fid_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "발행사"
},
"fid_input_rmnn_dynu_1": {
"name": "fid_input_rmnn_dynu_1",
"type": "str",
"required": true,
"default_value": null,
"description": "입력잔존일수"
},
"fid_div_cls_code": {
"name": "fid_div_cls_code",
"type": "str",
"required": true,
"default_value": null,
"description": "콜풋구분코드"
},
"fid_input_price_1": {
"name": "fid_input_price_1",
"type": "str",
"required": true,
"default_value": null,
"description": "가격(이상)"
},
"fid_input_price_2": {
"name": "fid_input_price_2",
"type": "str",
"required": true,
"default_value": null,
"description": "가격(이하)"
},
"fid_input_vol_1": {
"name": "fid_input_vol_1",
"type": "str",
"required": true,
"default_value": null,
"description": "거래량(이상)"
},
"fid_input_vol_2": {
"name": "fid_input_vol_2",
"type": "str",
"required": true,
"default_value": null,
"description": "거래량(이하)"
},
"fid_input_date_1": {
"name": "fid_input_date_1",
"type": "str",
"required": true,
"default_value": null,
"description": "조회기준일"
},
"fid_rank_sort_cls_code": {
"name": "fid_rank_sort_cls_code",
"type": "str",
"required": true,
"default_value": null,
"description": "순위정렬구분코드"
},
"fid_blng_cls_code": {
"name": "fid_blng_cls_code",
"type": "str",
"required": true,
"default_value": null,
"description": "소속구분코드"
},
"fid_input_iscd_2": {
"name": "fid_input_iscd_2",
"type": "str",
"required": true,
"default_value": null,
"description": "LP발행사"
},
"fid_input_date_2": {
"name": "fid_input_date_2",
"type": "str",
"required": true,
"default_value": null,
"description": "만기일-최종거래일조회"
},
"tr_cont": {
"name": "tr_cont",
"type": "str",
"required": true,
"default_value": "",
"description": "연속 거래 여부"
},
"dataframe": {
"name": "dataframe",
"type": "pd.DataFrame",
"required": false,
"default_value": null,
"description": "누적 데이터프레임"
},
"depth": {
"name": "depth",
"type": "int",
"required": true,
"default_value": 0,
"description": "현재 재귀 깊이"
},
"max_depth": {
"name": "max_depth",
"type": "int",
"required": true,
"default_value": 10,
"description": "최대 재귀 깊이 (기본값: 10)"
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
{
"tool_info": {
"introduce": "한국투자증권의 ETF/ETN OPEN API를 활용합니다.",
"introduce_append": "",
"examples": [
{
"api_type": "inquire_price",
"params": {
"fid_cond_mrkt_div_code": "J",
"fid_input_iscd": "123456"
}
},
{
"api_type": "nav_comparison_trend",
"params": {
"fid_cond_mrkt_div_code": "J",
"fid_input_iscd": "069500"
}
}
]
},
"apis": {
"inquire_price": {
"category": "[국내주식] 기본시세",
"name": "ETF/ETN 현재가",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/etfetn/inquire_price",
"method": "inquire_price",
"api_path": "/uapi/etfetn/v1/quotations/inquire-price",
"params": {
"fid_cond_mrkt_div_code": {
"name": "fid_cond_mrkt_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 조건 시장 분류 코드 (ex. J:KRX, NX:NXT, UN:통합)"
},
"fid_input_iscd": {
"name": "fid_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 입력 종목코드 (ex. 123456)"
}
}
},
"nav_comparison_trend": {
"category": "[국내주식] 기본시세",
"name": "NAV 비교추이(종목)",
"github_url": "https://github.com/koreainvestment/open-trading-api/tree/main/examples_llm/etfetn/nav_comparison_trend",
"method": "nav_comparison_trend",
"api_path": "/uapi/etfetn/v1/quotations/nav-comparison-trend",
"params": {
"fid_cond_mrkt_div_code": {
"name": "fid_cond_mrkt_div_code",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 조건 시장 분류 코드 (ex. J)"
},
"fid_input_iscd": {
"name": "fid_input_iscd",
"type": "str",
"required": true,
"default_value": null,
"description": "[필수] 입력 종목코드"
}
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
from .base import Base
from .updated import Updated
# 툴별 마스터 모델들
from .domestic_stock import DomesticStockMaster
from .overseas_stock import OverseasStockMaster
from .domestic_futureoption import DomesticFutureoptionMaster
from .overseas_futureoption import OverseasFutureoptionMaster
from .domestic_bond import DomesticBondMaster
from .etfetn import EtfetnMaster
from .elw import ElwMaster
from .auth import AuthMaster
# 모든 모델들을 리스트로 제공
ALL_MODELS = [
# 툴별 마스터 모델들
DomesticStockMaster,
OverseasStockMaster,
DomesticFutureoptionMaster,
OverseasFutureoptionMaster,
DomesticBondMaster,
EtfetnMaster,
ElwMaster,
AuthMaster,
# 업데이트 상태 추적
Updated
]

View File

@@ -0,0 +1,11 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class AuthMaster(Base):
"""인증 마스터"""
__tablename__ = 'auth_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import declarative_base
# SQLAlchemy Base 클래스
Base = declarative_base()

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class DomesticBondMaster(Base):
"""국내채권 마스터"""
__tablename__ = 'domestic_bond_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class DomesticFutureoptionMaster(Base):
"""국내선물옵션 마스터"""
__tablename__ = 'domestic_futureoption_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class DomesticStockMaster(Base):
"""국내주식 마스터"""
__tablename__ = 'domestic_stock_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class ElwMaster(Base):
"""ELW 마스터"""
__tablename__ = 'elw_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class EtfetnMaster(Base):
"""ETF/ETN 마스터"""
__tablename__ = 'etfetn_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class OverseasFutureoptionMaster(Base):
"""해외선물옵션 마스터"""
__tablename__ = 'overseas_futureoption_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,12 @@
from sqlalchemy import Column, Integer, String
from .base import Base
class OverseasStockMaster(Base):
"""해외주식 마스터"""
__tablename__ = 'overseas_stock_master'
id = Column(Integer, primary_key=True)
name = Column(String(50), index=True) # 종목명
code = Column(String(50), index=True) # 종목코드
ex = Column(String(30), index=True) # 거래소 코드

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, String, DateTime
from .base import Base
class Updated(Base):
"""마스터파일 업데이트 상태 추적 테이블"""
__tablename__ = 'updated'
id = Column(Integer, primary_key=True)
tool_name = Column(String(50), nullable=False, unique=True, index=True) # 툴명 (예: domestic_stock, overseas_stock)
updated_at = Column(DateTime, nullable=False) # 마지막 업데이트 시간
def __repr__(self):
return f"<Updated(tool_name='{self.tool_name}', updated_at='{self.updated_at}')>"

View File

@@ -0,0 +1,3 @@
from .decorator import singleton
from .plugin import setup_environment, EnvironmentConfig, setup_kis_config, MasterFileManager
from .middleware import EnvironmentMiddleware

View File

@@ -0,0 +1,32 @@
import threading
def singleton(cls):
"""
클래스 형태를 유지하는 싱글톤 데코레이터.
- 여러 번 호출해도 동일 인스턴스 반환
- __init__은 최초 1회만 실행
- 스레드-세이프
"""
cls.__singleton_lock__ = getattr(cls, "__singleton_lock__", threading.Lock())
cls.__singleton_instance__ = getattr(cls, "__singleton_instance__", None)
orig_init = cls.__init__
def __init__(self, *args, **kwargs):
# 최초 1회만 실제 __init__ 수행
if getattr(self, "__initialized__", False):
return
orig_init(self, *args, **kwargs)
setattr(self, "__initialized__", True)
def __new__(inner_cls, *args, **kwargs):
if inner_cls.__singleton_instance__ is None:
with inner_cls.__singleton_lock__:
if inner_cls.__singleton_instance__ is None:
inner_cls.__singleton_instance__ = object.__new__(inner_cls)
return inner_cls.__singleton_instance__
cls.__init__ = __init__
cls.__new__ = staticmethod(__new__)
return cls

View File

@@ -0,0 +1,6 @@
# 기존 컨텍스트 상수들
CONTEXT_REQUEST_ID = "context_request_id"
CONTEXT_ENVIRONMENT = "context_environment"
CONTEXT_STARTED_AT = "context_started_at"
CONTEXT_ENDED_AT = "context_ended_at"
CONTEXT_ELAPSED_SECONDS = "context_elapsed_seconds"

View File

@@ -0,0 +1,46 @@
import uuid
from datetime import datetime
import time
from fastmcp.server.middleware import Middleware, MiddlewareContext
import module.factory as factory
# 기본 미들웨어
class EnvironmentMiddleware(Middleware):
def __init__(self, environment):
self.environment = environment
async def on_call_tool(self, context: MiddlewareContext, call_next):
ctx = context.fastmcp_context
# time counter start
t0 = time.perf_counter()
# started_at
started_dt = datetime.now()
ctx.set_state(factory.CONTEXT_STARTED_AT, started_dt.strftime("%Y-%m-%d %H:%M:%S"))
# request id
request_id = uuid.uuid4().hex
ctx.set_state(factory.CONTEXT_REQUEST_ID, request_id)
# context setup
ctx.set_state(factory.CONTEXT_ENVIRONMENT, self.environment)
try:
result = await call_next(context)
return result
except Exception as e:
raise e
finally:
# ended at
ended_at = datetime.now()
ctx.set_state(factory.CONTEXT_ENDED_AT, ended_at.strftime("%Y-%m-%d %H:%M:%S"))
# time counter end
elapsed_sec = time.perf_counter() - t0
ctx.set_state(factory.CONTEXT_ELAPSED_SECONDS, round(elapsed_sec, 2))

View File

@@ -0,0 +1,4 @@
from .kis import setup_kis_config
from .environment import setup_environment, EnvironmentConfig
from .master_file import MasterFileManager
from .database import DatabaseEngine, Database

View File

@@ -0,0 +1,540 @@
from typing import Any, Dict, List, Optional, Type, Union
from sqlalchemy import create_engine, Engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.exc import SQLAlchemyError
import logging
import os
from datetime import datetime
logger = logging.getLogger(__name__)
class DatabaseEngine:
"""1 SQLite 파일 : 1 엔진을 관리하는 클래스"""
def __init__(self, db_path: str, models: List[Type]):
"""
Args:
db_path: SQLite 파일 경로
models: 해당 데이터베이스에 포함될 모델 클래스들의 리스트
"""
self.db_path = db_path
self.models = models
self.engine: Optional[Engine] = None
self.SessionLocal: Optional[sessionmaker] = None
self._initialize_engine()
def _initialize_engine(self):
"""데이터베이스 엔진 초기화"""
try:
# SQLite 연결 문자열 생성
db_url = f"sqlite:///{self.db_path}"
# 엔진 생성
self.engine = create_engine(
db_url,
echo=False, # SQL 로그 출력 여부
pool_pre_ping=True, # 연결 상태 확인
connect_args={"check_same_thread": False} # SQLite 멀티스레드 지원
)
# 세션 팩토리 생성
self.SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=self.engine
)
# 테이블 생성
self._create_tables()
logger.info(f"Database engine initialized: {self.db_path}")
except Exception as e:
logger.error(f"Failed to initialize database engine {self.db_path}: {e}")
raise
def _create_tables(self):
"""모든 모델의 테이블 생성"""
try:
from model.base import Base
Base.metadata.create_all(bind=self.engine)
logger.info(f"Tables created for {self.db_path}")
except Exception as e:
logger.error(f"Failed to create tables for {self.db_path}: {e}")
raise
def get_session(self) -> Session:
"""새로운 데이터베이스 세션 반환"""
if not self.SessionLocal:
raise RuntimeError("Database engine not initialized")
return self.SessionLocal()
def insert(self, model_instance: Any) -> Any:
"""
모델 인스턴스를 데이터베이스에 삽입
Args:
model_instance: 삽입할 모델 인스턴스
Returns:
삽입된 모델 인스턴스 (ID 포함)
"""
session = self.get_session()
try:
session.add(model_instance)
session.commit()
session.refresh(model_instance)
logger.info(f"Inserted record: {type(model_instance).__name__}")
return model_instance
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to insert record: {e}")
raise
finally:
session.close()
def update(self, model_class: Type, record_id: int, update_data: Dict[str, Any]) -> Optional[Any]:
"""
ID로 레코드 업데이트
Args:
model_class: 업데이트할 모델 클래스
record_id: 업데이트할 레코드의 ID
update_data: 업데이트할 필드와 값의 딕셔너리
Returns:
업데이트된 모델 인스턴스 또는 None
"""
session = self.get_session()
try:
# 레코드 조회
record = session.query(model_class).filter(model_class.id == record_id).first()
if not record:
logger.warning(f"Record not found: {model_class.__name__} ID {record_id}")
return None
# 필드 업데이트
for field, value in update_data.items():
if hasattr(record, field):
setattr(record, field, value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
session.commit()
session.refresh(record)
logger.info(f"Updated record: {model_class.__name__} ID {record_id}")
return record
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to update record: {e}")
raise
finally:
session.close()
def delete(self, model_class: Type, record_id: int) -> bool:
"""
ID로 레코드 삭제
Args:
model_class: 삭제할 모델 클래스
record_id: 삭제할 레코드의 ID
Returns:
삭제 성공 여부
"""
session = self.get_session()
try:
# 레코드 조회
record = session.query(model_class).filter(model_class.id == record_id).first()
if not record:
logger.warning(f"Record not found: {model_class.__name__} ID {record_id}")
return False
session.delete(record)
session.commit()
logger.info(f"Deleted record: {model_class.__name__} ID {record_id}")
return True
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to delete record: {e}")
raise
finally:
session.close()
def list(self, model_class: Type, filters: Optional[Dict[str, Any]] = None,
limit: Optional[int] = None, offset: Optional[int] = None) -> List[Any]:
"""
조건에 맞는 레코드 목록 조회
Args:
model_class: 조회할 모델 클래스
filters: 필터 조건 딕셔너리 {field: value}
limit: 조회할 최대 개수
offset: 건너뛸 개수
Returns:
조회된 레코드 리스트
"""
session = self.get_session()
try:
query = session.query(model_class)
# 필터 적용
if filters:
for field, value in filters.items():
if hasattr(model_class, field):
query = query.filter(getattr(model_class, field) == value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
# 페이징 적용
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
results = query.all()
logger.info(f"Listed {len(results)} records: {model_class.__name__}")
return results
except SQLAlchemyError as e:
logger.error(f"Failed to list records: {e}")
raise
finally:
session.close()
def get(self, model_class: Type, filters: Dict[str, Any]) -> Optional[Any]:
"""
조건에 맞는 첫 번째 레코드 조회
Args:
model_class: 조회할 모델 클래스
filters: 필터 조건 딕셔너리 {field: value}
Returns:
조회된 레코드 또는 None
"""
session = self.get_session()
try:
query = session.query(model_class)
# 필터 적용
for field, value in filters.items():
if hasattr(model_class, field):
query = query.filter(getattr(model_class, field) == value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
result = query.first()
if result:
logger.info(f"Found record: {model_class.__name__}")
else:
logger.info(f"No record found: {model_class.__name__}")
return result
except SQLAlchemyError as e:
logger.error(f"Failed to get record: {e}")
raise
finally:
session.close()
def count(self, model_class: Type, filters: Optional[Dict[str, Any]] = None) -> int:
"""
조건에 맞는 레코드 개수 조회
Args:
model_class: 조회할 모델 클래스
filters: 필터 조건 딕셔너리 {field: value}
Returns:
레코드 개수
"""
session = self.get_session()
try:
query = session.query(model_class)
# 필터 적용
if filters:
for field, value in filters.items():
if hasattr(model_class, field):
query = query.filter(getattr(model_class, field) == value)
else:
logger.warning(f"Field '{field}' not found in {model_class.__name__}")
count = query.count()
logger.info(f"Counted {count} records: {model_class.__name__}")
return count
except SQLAlchemyError as e:
logger.error(f"Failed to count records: {e}")
raise
finally:
session.close()
def bulk_replace_master_data(self, model_class: Type, data_list: List[Dict], master_name: str) -> int:
"""
마스터 데이터 추가 (INSERT만) - 카테고리 레벨에서 이미 삭제됨
Args:
model_class: 마스터 데이터 모델 클래스
data_list: 삽입할 데이터 리스트 (딕셔너리 리스트)
master_name: 마스터파일명 (로깅용)
Returns:
삽입된 레코드 수
"""
session = self.get_session()
try:
# 새 데이터 배치 삽입 (카테고리 레벨에서 이미 삭제되었으므로 INSERT만)
if data_list:
# 배치 크기 설정 (메모리 효율성을 위해 1000개씩)
batch_size = 1000
total_inserted = 0
for i in range(0, len(data_list), batch_size):
batch = data_list[i:i + batch_size]
batch_objects = []
for data in batch:
# 모든 값을 문자열로 강제 변환 (SQLAlchemy 타입 추론 방지)
str_data = {key: str(value) if value is not None else None for key, value in data.items()}
# 딕셔너리를 모델 인스턴스로 변환
obj = model_class(**str_data)
batch_objects.append(obj)
# 배치 삽입
session.bulk_save_objects(batch_objects)
total_inserted += len(batch_objects)
# 중간 커밋 (메모리 절약)
if i + batch_size < len(data_list):
session.commit()
logger.info(f"Inserted batch {i//batch_size + 1}: {len(batch_objects)} records")
# 최종 커밋
session.commit()
logger.info(f"Bulk replace completed: {total_inserted} records inserted into {model_class.__name__}")
return total_inserted
else:
logger.warning(f"No data to insert for {master_name}")
return 0
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to bulk replace master data for {master_name}: {e}")
raise
finally:
session.close()
def update_master_timestamp(self, tool_name: str, record_count: int = None) -> bool:
"""
마스터파일 업데이트 시간 기록
Args:
tool_name: 툴명 (예: domestic_stock, overseas_stock)
record_count: 레코드 수 (선택사항)
Returns:
업데이트 성공 여부
"""
from model.updated import Updated
session = self.get_session()
try:
# 기존 레코드 조회
existing_record = session.query(Updated).filter(Updated.tool_name == tool_name).first()
if existing_record:
# 기존 레코드 업데이트
existing_record.updated_at = datetime.now()
logger.info(f"Updated timestamp for {tool_name}")
else:
# 새 레코드 생성
new_record = Updated(
tool_name=tool_name,
updated_at=datetime.now()
)
session.add(new_record)
logger.info(f"Created new timestamp record for {tool_name}")
session.commit()
return True
except SQLAlchemyError as e:
session.rollback()
logger.error(f"Failed to update master timestamp for {tool_name}: {e}")
return False
finally:
session.close()
def get_master_update_time(self, tool_name: str) -> Optional[datetime]:
"""
마스터파일 마지막 업데이트 시간 조회
Args:
tool_name: 툴명 (예: domestic_stock, overseas_stock)
Returns:
마지막 업데이트 시간 또는 None
"""
from model.updated import Updated
session = self.get_session()
try:
record = session.query(Updated).filter(Updated.tool_name == tool_name).first()
if record:
logger.info(f"Found update time for {tool_name}: {record.updated_at}")
return record.updated_at
else:
logger.info(f"No update record found for {tool_name}")
return None
except SQLAlchemyError as e:
logger.error(f"Failed to get master update time for {tool_name}: {e}")
return None
finally:
session.close()
def is_master_data_available(self, model_class: Type) -> bool:
"""
마스터 데이터 존재 여부 확인
Args:
model_class: 마스터 데이터 모델 클래스
Returns:
데이터 존재 여부
"""
session = self.get_session()
try:
count = session.query(model_class).count()
available = count > 0
logger.info(f"Master data availability check for {model_class.__name__}: {available} ({count} records)")
return available
except SQLAlchemyError as e:
logger.error(f"Failed to check master data availability for {model_class.__name__}: {e}")
return False
finally:
session.close()
def close(self):
"""데이터베이스 연결 종료"""
if self.engine:
self.engine.dispose()
logger.info(f"Database engine closed: {self.db_path}")
def __repr__(self):
return f"DatabaseEngine(db_path='{self.db_path}', models={len(self.models)})"
class Database:
"""데이터베이스 엔진들을 관리하는 Singleton 클래스"""
_instance: Optional['Database'] = None
_initialized: bool = False
def __new__(cls) -> 'Database':
"""Singleton 패턴 구현"""
if cls._instance is None:
cls._instance = super(Database, cls).__new__(cls)
return cls._instance
def __init__(self):
"""초기화 (한 번만 실행)"""
if not self._initialized:
self.dbs: Dict[str, DatabaseEngine] = {}
self._initialized = True
logger.info("Database singleton instance created")
def new(self, db_dir: str = "configs/master") -> None:
"""
마스터 데이터베이스 엔진 초기화
Args:
db_dir: 데이터베이스 파일이 저장될 디렉토리
"""
try:
# 데이터베이스 디렉토리 생성
os.makedirs(db_dir, exist_ok=True)
# 하나의 통합 마스터 데이터베이스 엔진 생성
self._create_master_engine(db_dir)
logger.info(f"Master database engine initialized: '{db_dir}/master.db'")
logger.info(f"Available databases: {list(self.dbs.keys())}")
except Exception as e:
logger.error(f"Failed to initialize master database: {e}")
raise
def _create_master_engine(self, db_dir: str):
"""통합 마스터 데이터베이스 엔진 생성"""
from model import ALL_MODELS
db_path = os.path.join(db_dir, "master.db")
self.dbs["master"] = DatabaseEngine(db_path, ALL_MODELS)
logger.info("Created master database engine with all models")
def get_by_name(self, name: str) -> DatabaseEngine:
"""
이름으로 데이터베이스 엔진 조회
Args:
name: 데이터베이스 이름
Returns:
DatabaseEngine 인스턴스
Raises:
KeyError: 해당 이름의 데이터베이스가 없는 경우
"""
if name not in self.dbs:
available_dbs = list(self.dbs.keys())
raise KeyError(f"Database '{name}' not found. Available databases: {available_dbs}")
return self.dbs[name]
def get_available_databases(self) -> List[str]:
"""사용 가능한 데이터베이스 이름 목록 반환"""
return list(self.dbs.keys())
def is_initialized(self) -> bool:
"""데이터베이스가 초기화되었는지 확인"""
return len(self.dbs) > 0
def ensure_initialized(self, db_dir: str = "configs/master") -> bool:
"""데이터베이스가 초기화되지 않은 경우에만 초기화"""
if not self.is_initialized():
try:
self.new(db_dir)
logger.info("Database initialized on demand")
return True
except Exception as e:
logger.error(f"Failed to initialize database on demand: {e}")
return False
return True
def close_all(self):
"""모든 데이터베이스 연결 종료"""
for name, engine in self.dbs.items():
try:
engine.close()
logger.info(f"Closed database: {name}")
except Exception as e:
logger.error(f"Failed to close database {name}: {e}")
self.dbs.clear()
logger.info("All database connections closed")
def __repr__(self):
return f"Database(engines={len(self.dbs)}, names={list(self.dbs.keys())})"
def __del__(self):
"""소멸자 - 모든 연결 정리"""
if hasattr(self, 'dbs') and self.dbs:
self.close_all()

View File

@@ -0,0 +1,43 @@
import logging
import os
from collections import namedtuple
from dotenv import load_dotenv
# Environment 설정을 위한 namedtuple 정의
EnvironmentConfig = namedtuple('EnvironmentConfig', [
'mcp_type', 'mcp_host', 'mcp_port', 'mcp_path'
])
def setup_environment(env: str) -> EnvironmentConfig:
# get api env
if not env:
logging.error("Environment variable ENV not defined")
exit(1)
# load .env
dotenv_path = os.path.join(os.getcwd(), f".env.{env}")
if not os.path.isfile(dotenv_path):
logging.error(f"Environment variable file .env.{env} not found")
exit(1)
load_dotenv(dotenv_path=dotenv_path)
# return environment
# MCP_TYPE 검증 및 기본값 설정
mcp_type = os.getenv("MCP_TYPE", "stdio")
if mcp_type not in ['stdio', 'sse', 'streamable-http']:
logging.warning(f"Invalid MCP_TYPE: {mcp_type}, using default: stdio")
mcp_type = "stdio"
# MCP_PORT가 빈 문자열이면 기본값 사용
mcp_port_str = os.getenv("MCP_PORT", "8000")
mcp_port = int(mcp_port_str) if mcp_port_str.strip() else 8000
return EnvironmentConfig(
mcp_type=mcp_type,
mcp_host=os.getenv("MCP_HOST", "localhost"),
mcp_port=mcp_port,
mcp_path=os.getenv("MCP_PATH", "/mcp")
)

View File

@@ -0,0 +1,163 @@
import logging
import os
import requests
import yaml
def setup_kis_config(force_update=False):
"""KIS 설정 파일 자동 생성 (템플릿 다운로드 + 환경변수로 값 덮어쓰기)
Args:
force_update (bool): True면 기존 파일이 있어도 강제로 덮어쓰기
"""
# kis_auth.py와 동일한 경로 생성 방식 사용
kis_config_dir = os.path.join(os.path.expanduser("~"), "KIS", "config")
# KIS 설정 디렉토리 생성
os.makedirs(kis_config_dir, exist_ok=True)
# 설정 파일 경로
kis_config_path = os.path.join(kis_config_dir, "kis_devlp.yaml")
# 기존 파일 존재 확인
if os.path.exists(kis_config_path) and not force_update:
logging.info(f"✅ KIS 설정 파일이 이미 존재합니다: {kis_config_path}")
logging.info("기존 파일을 사용합니다. 강제 업데이트가 필요한 경우 force_update=True 옵션을 사용하세요.")
return True
# 1. kis_devlp.yaml 템플릿 다운로드
template_url = "https://raw.githubusercontent.com/koreainvestment/open-trading-api/refs/heads/main/kis_devlp.yaml"
try:
logging.info("KIS 설정 템플릿을 다운로드 중...")
response = requests.get(template_url, timeout=30)
response.raise_for_status()
# 원본 템플릿 텍스트 보존
template_content = response.text
logging.info("✅ KIS 설정 템플릿 다운로드 완료")
except Exception as e:
logging.error(f"❌ KIS 설정 템플릿 다운로드 실패: {e}")
return False
# 2. 환경변수로 민감한 정보 덮어쓰기
# 필수값 (누락 시 경고)
app_key = os.getenv("KIS_APP_KEY")
app_secret = os.getenv("KIS_APP_SECRET")
if not app_key or not app_secret:
logging.warning("⚠️ 필수 환경변수가 설정되지 않았습니다:")
if not app_key:
logging.warning(" - KIS_APP_KEY")
if not app_secret:
logging.warning(" - KIS_APP_SECRET")
logging.warning("실제 거래 API 사용이 불가능할 수 있습니다.")
# 선택적 값들 (누락 시 빈값 또는 기본값)
paper_app_key = os.getenv("KIS_PAPER_APP_KEY", "")
paper_app_secret = os.getenv("KIS_PAPER_APP_SECRET", "")
hts_id = os.getenv("KIS_HTS_ID", "")
acct_stock = os.getenv("KIS_ACCT_STOCK", "")
acct_future = os.getenv("KIS_ACCT_FUTURE", "")
paper_stock = os.getenv("KIS_PAPER_STOCK", "")
paper_future = os.getenv("KIS_PAPER_FUTURE", "")
prod_type = os.getenv("KIS_PROD_TYPE", "01") # 기본값: 종합계좌
url_rest = os.getenv("KIS_URL_REST", "")
url_rest_paper = os.getenv("KIS_URL_REST_PAPER", "")
url_ws = os.getenv("KIS_URL_WS", "")
url_ws_paper = os.getenv("KIS_URL_WS_PAPER", "")
# 3. YAML 파싱하여 값 업데이트
try:
# YAML 파싱 (주석 보존을 위해 ruamel.yaml 사용하거나, 간단히 pyyaml 사용)
config = yaml.safe_load(template_content)
# 환경변수 값이 있으면 해당 필드만 업데이트
if app_key:
config['my_app'] = app_key
logging.info(f"✅ 실전 App Key 설정 완료")
if app_secret:
config['my_sec'] = app_secret
logging.info(f"✅ 실전 App Secret 설정 완료")
if paper_app_key:
config['paper_app'] = paper_app_key
logging.info(f"✅ 모의 App Key 설정 완료")
if paper_app_secret:
config['paper_sec'] = paper_app_secret
logging.info(f"✅ 모의 App Secret 설정 완료")
if hts_id:
config['my_htsid'] = hts_id
logging.info(f"✅ HTS ID 설정 완료: {hts_id}")
else:
logging.warning("⚠️ KIS_HTS_ID 환경변수가 설정되지 않았습니다.")
if acct_stock:
config['my_acct_stock'] = acct_stock
logging.info(f"✅ 증권계좌 설정 완료")
if acct_future:
config['my_acct_future'] = acct_future
logging.info(f"✅ 선물옵션계좌 설정 완료")
if paper_stock:
config['my_paper_stock'] = paper_stock
logging.info(f"✅ 모의 증권계좌 설정 완료")
if paper_future:
config['my_paper_future'] = paper_future
logging.info(f"✅ 모의 선물옵션계좌 설정 완료")
if prod_type != "01": # 기본값이 아닌 경우만 업데이트
config['my_prod'] = prod_type
logging.info(f"✅ 계좌상품코드 설정 완료: {prod_type}")
# URL 설정 업데이트 (직접 필드)
if url_rest:
config['prod'] = url_rest
logging.info(f"✅ 실전 REST URL 설정 완료")
if url_rest_paper:
config['vps'] = url_rest_paper
logging.info(f"✅ 모의 REST URL 설정 완료")
if url_ws:
config['ops'] = url_ws
logging.info(f"✅ 실전 WebSocket URL 설정 완료")
if url_ws_paper:
config['vops'] = url_ws_paper
logging.info(f"✅ 모의 WebSocket URL 설정 완료")
# YAML로 다시 변환
updated_content = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
except yaml.YAMLError as e:
logging.error(f"❌ YAML 파싱 오류: {e}")
logging.info("문자열 치환 방식으로 대체합니다...")
# 실패 시 기존 문자열 치환 방식 사용
updated_content = template_content
if app_key:
updated_content = updated_content.replace('my_app: "앱키"', f'my_app: "{app_key}"')
if app_secret:
updated_content = updated_content.replace('my_sec: "앱키 시크릿"', f'my_sec: "{app_secret}"')
if hts_id:
updated_content = updated_content.replace('my_htsid: "사용자 HTS ID"', f'my_htsid: "{hts_id}"')
# ... 나머지 기존 로직
# 4. 수정된 설정을 파일로 저장 (원본 구조 보존)
try:
with open(kis_config_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
logging.info(f"✅ KIS 설정 파일이 생성되었습니다: {kis_config_path}")
# 설정 요약 출력
logging.info("📋 KIS 설정 요약:")
logging.info(f" - 실제 거래: {'' if app_key and app_secret else ''}")
logging.info(f" - 모의 거래: {'' if paper_app_key and paper_app_secret else ''}")
logging.info(f" - 계좌번호: {'' if any([acct_stock, acct_future, paper_stock, paper_future]) else ''}")
logging.info(f" - URL 설정: {'' if any([url_rest, url_rest_paper, url_ws, url_ws_paper]) else ''}")
return True
except Exception as e:
logging.error(f"❌ KIS 설정 파일 생성 실패: {e}")
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
[project]
name = "korea-investment-api-mcp"
version = "0.1.0"
description = "한국투자증권 OPEN API MCP 서버 - LLM이 쉽게 사용할 수 있는 금융 API 래퍼"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastmcp>=2.11.2",
"pandas>=2.3.1",
"pycryptodome>=3.23.0",
"pydantic>=2.11.7",
"python-dotenv>=1.1.1",
"requests>=2.32.4",
"websockets>=15.0.1",
"PyYAML>=6.0.1",
"sqlalchemy>=2.0.43",
]

View File

@@ -0,0 +1,105 @@
import logging
import os
import platform
import sys
from fastmcp import FastMCP
from module import setup_environment, EnvironmentMiddleware, EnvironmentConfig, setup_kis_config
from module.plugin import Database
from tools import *
logging.basicConfig(
level=logging.DEBUG, # DEBUG 이상 (DEBUG, INFO, WARNING...) 모두 출력
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
def main():
env = os.getenv("ENV", None)
# 환경 설정
logging.info("setup environment ...")
env_config = setup_environment(env=env)
# KIS 설정 자동 생성 (템플릿 다운로드 + 값 덮어쓰기)
logging.info("setup KIS configuration ...")
if not setup_kis_config(force_update=env == "live"):
logging.warning("KIS 설정 파일 생성에 실패했습니다. 수동으로 설정해주세요.")
# 데이터베이스 초기화
logging.info("setup database ...")
db = None
db_exists = False
try:
db = Database()
db_exists = os.path.exists(os.path.join("configs/master", "master.db"))
db.new(db_dir="configs/master")
logging.info(f"📁 Available databases: {db.get_available_databases()}")
except Exception as e:
logging.error(f"❌ Database initialization failed: {e}")
sys.exit(1)
# MCP 서버 설정
mcp_server = FastMCP(
name="My Awesome MCP Server",
instructions="This is a server for a specific project.",
version="1.0.0",
stateless_http=False,
)
# middleware
mcp_server.add_middleware(EnvironmentMiddleware(environment=env_config))
# tools 등록
DomesticStockTool().register(mcp_server=mcp_server)
DomesticFutureOptionTool().register(mcp_server=mcp_server)
DomesticBondTool().register(mcp_server=mcp_server)
OverseasStockTool().register(mcp_server=mcp_server)
OverseasFutureOptionTool().register(mcp_server=mcp_server)
ElwTool().register(mcp_server=mcp_server)
EtfEtnTool().register(mcp_server=mcp_server)
AuthTool().register(mcp_server=mcp_server)
# MCP 서버 실행 방식 결정
logging.info(f"🚀 MCP 서버를 {env_config.mcp_type} 모드로 시작합니다...")
if env_config.mcp_type == "stdio":
# stdio 모드 (기본값)
logging.info("📝 stdio 모드로 MCP 서버를 시작합니다.")
mcp_server.run(
transport="stdio"
)
elif env_config.mcp_type == "sse":
# HTTP 모드로 실행
logging.info(f"🌐 Server Sent Event 모드로 MCP 서버를 시작합니다: {env_config.mcp_host}:{env_config.mcp_port}")
mcp_server.run(
transport="sse",
host=env_config.mcp_host,
port=env_config.mcp_port,
path=env_config.mcp_path,
)
elif env_config.mcp_type == "streamable-http":
# HTTP 모드로 실행
logging.info(f"🌐 HTTP 모드로 MCP 서버를 시작합니다: {env_config.mcp_host}:{env_config.mcp_port}")
mcp_server.run(
transport="streamable-http",
host=env_config.mcp_host,
port=env_config.mcp_port,
path=env_config.mcp_path,
)
else:
logging.error(f"❌ 지원하지 않는 MCP_TYPE: {env_config.mcp_type}")
sys.exit(1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logging.info("🛑 Application interrupted by user (Ctrl+C)")

View File

@@ -0,0 +1,8 @@
from .domestic_bond import DomesticBondTool
from .domestic_futureoption import DomesticFutureOptionTool
from .domestic_stock import DomesticStockTool
from .elw import ElwTool
from .etfetn import EtfEtnTool
from .overseas_futureoption import OverseasFutureOptionTool
from .overseas_stock import OverseasStockTool
from .auth import AuthTool

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class AuthTool(BaseTool):
@property
def tool_name(self) -> str:
return "auth"

View File

@@ -0,0 +1,818 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, List
import json
import os
import time
import shutil
import subprocess
import requests
from fastmcp import FastMCP, Context
from module.plugin import MasterFileManager
from module.plugin.database import Database
import module.factory as factory
class ApiExecutor:
"""API 실행 클래스 - GitHub에서 코드를 다운로드하고 실행"""
def __init__(self, tool_name: str):
"""초기화"""
self.tool_name = tool_name
self.temp_base_dir = "./tmp"
# 절대 경로로 venv python 설정
self.venv_python = os.path.join(os.getcwd(), ".venv", "bin", "python")
# temp 디렉토리 생성
os.makedirs(self.temp_base_dir, exist_ok=True)
def _create_temp_directory(self, request_id: str) -> str:
"""임시 디렉토리 생성"""
timestamp = int(time.time() * 1_000_000) # 나노초 단위
temp_dir = os.path.join(self.temp_base_dir, f"{timestamp}_{request_id}")
os.makedirs(temp_dir, exist_ok=True)
return temp_dir
@classmethod
def _download_file(cls, url: str, file_path: str) -> bool:
"""파일 다운로드"""
try:
response = requests.get(url, timeout=30)
response.raise_for_status()
with open(file_path, 'w', encoding='utf-8') as f:
f.write(response.text)
return True
except Exception as e:
print(f"파일 다운로드 실패: {url}, 오류: {str(e)}")
return False
def _download_kis_auth(self, temp_dir: str) -> bool:
"""kis_auth.py 다운로드"""
kis_auth_url = "https://raw.githubusercontent.com/koreainvestment/open-trading-api/main/examples_llm/kis_auth.py"
kis_auth_path = os.path.join(temp_dir, "kis_auth.py")
return self._download_file(kis_auth_url, kis_auth_path)
def _download_api_code(self, github_url: str, temp_dir: str, api_type: str) -> str:
"""API 코드 다운로드"""
# GitHub URL을 raw URL로 변환하고 api_type/api_type.py를 붙여서 실제 파일 경로 생성
raw_url = github_url.replace('/tree/', '/').replace('github.com', 'raw.githubusercontent.com')
full_url = f"{raw_url}/{api_type}.py"
api_code_path = os.path.join(temp_dir, "api_code.py")
if self._download_file(full_url, api_code_path):
return api_code_path
else:
raise Exception(f"API 코드 다운로드 실패: {full_url}")
@classmethod
def _extract_trenv_params_from_example(cls, api_code_content: str) -> Dict[str, str]:
"""예제 파일에서 trenv 사용 패턴 완전 추출"""
import re
# 🎯 완전 자동화: param_name=xxx.my_attr 패턴 찾기 (변수명 무관)
trenv_mapping_pattern = r'(\w+)=\w*\.(my_\w+)'
matches = re.findall(trenv_mapping_pattern, api_code_content)
dynamic_mappings = {}
discovered_mappings = []
for param_name, trenv_attr in matches:
# 발견된 매핑을 그대로 사용 (완전 자동화!)
trenv_value = f'ka._TRENV.{trenv_attr}'
# 소문자 버전 (함수 파라미터)
dynamic_mappings[param_name] = trenv_value
# 대문자 버전 (API 파라미터)
dynamic_mappings[param_name.upper()] = trenv_value
discovered_mappings.append(f"{param_name}=xxx.{trenv_attr}")
if discovered_mappings:
print(f"[🎯자동발견] {len(discovered_mappings)}개 매핑: {', '.join(discovered_mappings)}")
print(f"[🎯자동생성] {len(dynamic_mappings)}개 파라미터: {list(dynamic_mappings.keys())}")
else:
print("[🎯자동발견] .my_xxx 패턴 없음 - 조회성 API로 추정")
return dynamic_mappings
@classmethod
def _modify_api_code(cls, api_code_path: str, params: Dict[str, Any], api_type: str) -> str:
"""API 코드 수정 (파라미터 적용)"""
try:
import re
with open(api_code_path, 'r', encoding='utf-8') as f:
code = f.read()
# 1. sys.path.extend 관련 코드 제거
code = re.sub(r"sys\.path\.extend\(\[.*?\]\)", "", code, flags=re.DOTALL)
code = re.sub(r"import sys\n", "", code) # import sys도 제거
# 2. 코드에서 함수명과 시그니처 추출
function_match = re.search(r'def\s+(\w+)\s*\((.*?)\):', code, re.DOTALL)
if not function_match:
raise Exception("코드에서 함수를 찾을 수 없습니다.")
function_name = function_match.group(1)
function_params = function_match.group(2)
# 3. 함수가 max_depth 파라미터를 받는지 확인
has_max_depth = 'max_depth' in function_params
# 4. 파라미터 조정
adjusted_params = params.copy()
# max_depth 파라미터 처리
if has_max_depth:
# 함수가 max_depth를 받는 경우에만 처리
if 'max_depth' not in adjusted_params:
adjusted_params['max_depth'] = 1
print(f"[기본값] {function_name} 함수에 max_depth=1 설정")
else:
print(f"[사용자 설정] {function_name} 함수에 max_depth={adjusted_params['max_depth']} 사용")
else:
# 함수가 max_depth를 받지 않는 경우 제거
if 'max_depth' in adjusted_params:
del adjusted_params['max_depth']
print(f"[제거] {function_name} 함수는 max_depth 파라미터를 지원하지 않아 제거함")
# 🆕 동적으로 trenv 패턴 추출
dynamic_mappings = cls._extract_trenv_params_from_example(code)
# 기본 매핑과 동적 매핑 결합
account_mappings = {
'cano': 'ka._TRENV.my_acct', # 종합계좌번호 (변수 접근)
'acnt_prdt_cd': 'ka._TRENV.my_prod', # 계좌상품코드 (변수 접근)
'my_htsid': 'ka._TRENV.my_htsid', # HTS ID (변수 접근)
'user_id': 'ka._TRENV.my_htsid', # domestic_stock에서 발견된 변형
**dynamic_mappings # 동적으로 발견된 매핑 추가
}
for param_name, correct_value in account_mappings.items():
if param_name in function_params:
if param_name in adjusted_params:
original_value = adjusted_params[param_name]
adjusted_params[param_name] = correct_value
print(f"[보안강제] {function_name} 함수의 {param_name}='{original_value}'{correct_value} (LLM값 무시)")
else:
adjusted_params[param_name] = correct_value
print(f"[자동설정] {function_name} 함수에 {param_name}={correct_value} 설정")
# 거래소ID구분코드 처리 (API 타입 기반 추론)
if 'excg_id_dvsn_cd' in function_params and 'excg_id_dvsn_cd' not in adjusted_params:
if api_type.startswith('domestic'):
adjusted_params['excg_id_dvsn_cd'] = '"KRX"'
print(f"[추론] 국내 API({api_type})로 판단하여 excg_id_dvsn_cd='KRX' 설정")
else:
print(f"[경고] {api_type} API에서 excg_id_dvsn_cd 파라미터가 필요합니다. (예: NASD, NYSE, KRX)")
# overseas_stock 등은 사용자가 명시적으로 제공해야 함
# 5. 함수 호출 코드 생성 (ka.auth() - env_dv에 따라 분기)
# env_dv 값에 따른 인증 방식 결정
env_dv = params.get('env_dv', 'real')
if env_dv == 'demo':
auth_code = 'ka.auth("vps")'
print(f"[모의투자] {function_name} 함수에 ka.auth(\"vps\") 적용")
else:
auth_code = 'ka.auth()'
print(f"[실전투자] {function_name} 함수에 ka.auth() 적용")
call_code = f"""
# API 함수 호출
if __name__ == "__main__":
try:
# 인증 초기화 (env_dv={env_dv})
{auth_code}
result = {function_name}({", ".join([f"{k}={v if isinstance(v, str) and v.startswith('ka._TRENV.') else repr(v)}" for k, v in adjusted_params.items()])})
except TypeError as e:
# 🚨 핵심 오류 메시지만 출력
print(f"❌ TypeError: {{str(e)}}")
print()
# 파라미터 오류 처리 - LLM 교육용 메시지
if 'stock_name' in {repr(list(params.keys()))}:
print("💡 해결방법: find_stock_code로 종목을 검색하세요.")
else:
print("💡 해결방법: find_api_detail로 API 상세 정보를 확인하세요")
import sys
sys.exit(1)
try:
# N개 튜플 반환 함수 처리 (예: inquire_balance는 (df1, df2) 반환)
if isinstance(result, tuple):
# 튜플인 경우 - N개의 DataFrame 처리
output = {{}}
for i, item in enumerate(result):
if hasattr(item, 'to_dict'):
# DataFrame인 경우
output[f"output{{i+1}}"] = item.to_dict('records') if not item.empty else []
else:
# 일반 객체인 경우
output[f"output{{i+1}}"] = str(item)
import json
print(json.dumps(output, ensure_ascii=False, indent=2))
elif hasattr(result, 'empty') and not result.empty:
print(result.to_json(orient='records', force_ascii=False))
elif isinstance(result, dict):
import json
print(json.dumps(result, ensure_ascii=False))
elif isinstance(result, (list, tuple)):
import json
print(json.dumps(result, ensure_ascii=False))
else:
print(str(result))
except Exception as e:
print(f"오류 발생: {{str(e)}}")
"""
# 6. 코드 끝에 함수 호출 추가
modified_code = code + call_code
# 7. 수정된 코드 저장
with open(api_code_path, 'w', encoding='utf-8') as f:
f.write(modified_code)
return api_code_path
except Exception as e:
raise Exception(f"코드 수정 실패: {str(e)}")
def _execute_code(self, temp_dir: str, timeout: int = 15) -> Dict[str, Any]:
"""코드 실행"""
try:
# 실행할 파일 경로 (상대 경로로 변경)
api_code_path = "api_code.py"
# subprocess로 코드 실행
result = subprocess.run(
[self.venv_python, api_code_path],
cwd=temp_dir,
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode == 0:
# 성공 시 stdout을 결과로 반환
return {
"success": True,
"output": result.stdout,
"error": result.stderr
}
else:
# 실패 시 stderr와 stdout 모두 확인
error_message = result.stderr if result.stderr else result.stdout
return {
"success": False,
"output": result.stdout,
"error": error_message
}
except subprocess.TimeoutExpired:
return {
"success": False,
"error": f"실행 시간 초과 ({timeout}초)"
}
except Exception as e:
return {
"success": False,
"error": f"실행 중 오류: {str(e)}"
}
def _cleanup_temp_directory(self, temp_dir: str):
"""임시 디렉토리 정리"""
try:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as e:
print(f"임시 디렉토리 정리 실패: {temp_dir}, 오류: {str(e)}")
async def execute_api(self, ctx: Context, api_type: str, params: Dict[str, Any], github_url: str) -> Dict[str, Any]:
"""API 실행 메인 함수"""
temp_dir = None
start_time = time.time()
try:
await ctx.info(f"API 실행 시작: {api_type}")
# 1. 임시 디렉토리 생성
# FastMCP Context에서 request_id 안전하게 가져오기
try:
request_id = ctx.get_state(factory.CONTEXT_REQUEST_ID)
except:
request_id = "unknown"
temp_dir = self._create_temp_directory(request_id)
# 2. kis_auth.py 다운로드
if not self._download_kis_auth(temp_dir):
raise Exception("kis_auth.py 다운로드 실패")
# 3. API 코드 다운로드
api_code_path = self._download_api_code(github_url, temp_dir, api_type)
# 4. 코드 수정
self._modify_api_code(api_code_path, params, api_type)
# 5. 코드 실행
execution_result = self._execute_code(temp_dir)
# 6. 실행 시간 계산
execution_time = time.time() - start_time
# 7. 결과 반환
result = {
"success": execution_result["success"],
"api_type": api_type,
"params": params,
"message": f"{self.tool_name} API 호출 완료",
"execution_time": f"{execution_time:.2f}s",
"temp_dir": temp_dir,
"venv_used": True,
"cleanup_success": True
}
if execution_result["success"]:
result["data"] = execution_result["output"]
else:
result["error"] = execution_result["error"]
return result
except Exception as e:
await ctx.error(f"API 실행 중 오류: {str(e)}")
return {
"success": False,
"api_type": api_type,
"params": params,
"error": str(e),
"execution_time": f"{time.time() - start_time:.2f}s",
"temp_dir": temp_dir,
"venv_used": True,
"cleanup_success": False
}
finally:
# 8. 임시 디렉토리 정리
if temp_dir:
self._cleanup_temp_directory(temp_dir)
class BaseTool(ABC):
"""MCP 도구 기본 클래스"""
def __init__(self):
"""도구 초기화"""
self._load_config()
self.api_executor = ApiExecutor(self.tool_name)
self.master_file_manager = MasterFileManager(self.tool_name)
self.db = Database()
# ========== Abstract Properties ==========
@property
@abstractmethod
def tool_name(self) -> str:
"""도구 이름 (하위 클래스에서 구현 필수)"""
pass
# ========== Public Properties ==========
@property
def description(self) -> str:
"""도구 설명 (분류.json에서 동적 생성)"""
return self._generate_description()
@property
def config_file(self) -> str:
"""JSON 설정 파일 경로 (tool_name 기반 자동 생성)"""
return f"./configs/{self.tool_name}.json"
# ========== Public Methods ==========
def register(self, mcp_server: FastMCP) -> None:
"""MCP 서버에 도구 등록"""
mcp_server.tool(
self._run,
name=self.tool_name,
description=self.description,
)
# ========== Protected Methods ==========
def _load_config(self) -> None:
"""JSON 설정 파일 로드"""
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
self.config = json.load(f)
except FileNotFoundError:
# 임시로 빈 설정으로 초기화
self.config = {"apis": {}}
def _generate_description(self) -> str:
"""분류.json에서 도구 설명 동적 생성"""
try:
config_json_path = f"./configs/{self.tool_name}.json"
with open(config_json_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
tool_info = config_data.get("tool_info")
apis = config_data.get("apis", {})
if not tool_info:
return f"{self.tool_name} 도구의 tool_info가 없습니다."
# description 문자열 구성
lines = [tool_info.get("introduce", "")]
# introduce_append가 있으면 추가
introduce_append = tool_info.get("introduce_append", "").strip()
if introduce_append:
lines.append(introduce_append)
lines.append("") # 빈 줄
lines.append("[지원 기능]")
# API 목록 추가
for api_type, api_info in apis.items():
lines.append(f"- {api_info['name']} (api_type: \"{api_type}\")")
lines.append("") # 빈 줄
# 개선된 구조 적용
lines.append("📋 사용 방법:")
lines.append("1. find_api_detail로 API 상세 정보를 확인하세요")
lines.append("2. api_type을 선택하고 params에 필요한 파라미터를 입력하세요")
lines.append("3. 종목명으로 검색할 경우: stock_name='종목명' 파라미터를 사용하세요")
lines.append("4. 모의투자 시에는 env_dv='demo'를 추가하세요")
lines.append("")
lines.append("🔧 특별한 api_type 및 예시:")
lines.append(f"- find_stock_code (종목번호 검색) : {self.tool_name}({{ \"api_type\": \"find_stock_code\", \"params\": {{ \"stock_name\": \"삼성전자\" }} }})")
lines.append(f"- find_api_detail (API 정보 조회) : {self.tool_name}({{ \"api_type\": \"find_api_detail\", \"params\": {{ \"api_type\": \"inquire_price\" }} }})")
lines.append("")
lines.append("🔍 종목명 사용: stock_name=\"삼성전자\" → 자동으로 종목번호 변환하여 실행")
lines.append(f"{self.tool_name}({{ \"api_type\": \"inquire_price\", \"params\": {{ \"stock_name\": \"삼성전자\" }} }})")
lines.append("")
lines.append("💡 주요 파라미터:")
if self.tool_name.startswith('domestic'):
lines.append("- 시장코드(fid_cond_mrkt_div_code)='J'(KRX)/'NX'(넥스트레이드)/'UN'(통합)")
lines.append("- 매매구분(ord_dv)='buy'(매수)/'sell'(매도)")
lines.append("- 실전모의구분(env_dv)='real'(실전)/'demo'(모의)")
lines.append("")
lines.append("⚠️ 중요: API 호출 시 필수 주의사항")
lines.append("**API 실행 전 반드시 API 상세 문서의 파라미터를 확인하세요. Request Query Params와 Request Body 입력 시 추측이나 과거 실행 값 사용 금지, 확인된 API 상세 문서의 값을 사용하세요.**")
lines.append("**파라미터 description에 '공란'이 있는 경우 기본적으로 빈값으로 처리하되, 아닌 경우에는 값을 넣어도 됩니다.**")
lines.append("**🎯 모의투자 관련: 사용자가 '모의', '모의투자', '데모', '테스트' 등의 용어를 언급하거나 모의투자 관련 요청을 할 경우, 반드시 env_dv 파라미터를 'demo'로 설정하여 API를 호출해야 합니다. env_dv 파라미터가 있는 모든 API에서 모의투자 시에는 env_dv='demo', 실전투자 시에는 env_dv='real'을 사용합니다. 기본값은 'real'이므로 모의투자 요청 시 반드시 env_dv='demo'를 명시적으로 설정해주세요.**")
lines.append("")
lines.append("🔒 자동 처리되는 파라미터 (제공하지 마세요):")
lines.append("• cano (계좌번호), acnt_prdt_cd (계좌상품코드), my_htsid (HTS ID) - 시스템 자동 설정")
if self.tool_name.startswith('domestic'):
lines.append("• excg_id_dvsn_cd (거래소구분) - 국내 API는 자동으로 KRX 설정")
lines.append("")
# 예시 호출 추가
examples = tool_info.get("examples", [])
if examples:
lines.append("💻 예시 호출:")
for example in examples:
params_str = json.dumps(example.get('params', {}), ensure_ascii=False)
lines.append(
f"{self.tool_name}({{ \"api_type\": \"{example['api_type']}\",\"params\": {params_str} }})")
return "\n".join(lines)
except Exception as e:
return f"{self.tool_name} 도구 설명 생성 중 오류: {str(e)}"
async def _run(self, ctx: Context, api_type: str, params: dict) -> Dict[str, Any]:
"""공통 실행 로직"""
try:
await ctx.info(f"{self.tool_name} running with api_type: {api_type}")
# 1) 인자 구조 검증(가볍게)
if not api_type or not isinstance(params, dict):
return {
"ok": False,
"error": "MISSING_OR_INVALID_ARGS",
"missing": [k for k in ("api_type", "params") if
not (api_type if k == "api_type" else isinstance(params, dict))],
"invalid": [] if isinstance(params, dict) else [{"field": "params", "expected": "object"}],
}
# 2. 특별한 api_type 처리
if api_type == "find_stock_code":
return await self._handle_find_stock_code(ctx, params)
elif api_type == "find_api_detail":
return await self._handle_find_api_detail(ctx, params)
# 3. API 설정 조회
if api_type not in self.config['apis']:
return {"ok": False, "error": f"지원하지 않는 API 타입: {api_type}"}
# 4. 종목명 자동 처리 (stock_name이 있으면 자동으로 pdno 변환)
params = await self._process_stock_name(ctx, params)
# 5. 실제 실행 (래핑 함수 선택 → OPEN API 호출)
data = await self._run_api(ctx, api_type, params)
return {"ok": True, "data": data}
except Exception as e:
await ctx.error(f"실행 중 오류: {str(e)}")
return {"ok": False, "error": str(e)}
async def _run_api(self, ctx: Context, api_type: str, params: Dict[str, Any]) -> Any:
"""API 실행 - ApiExecutor 사용"""
try:
api_info = self.config['apis'][api_type]
github_url = api_info.get('github_url')
if not github_url:
return {"error": f"GitHub URL이 없습니다: {api_type}"}
# ApiExecutor를 사용하여 API 실행
result = await self.api_executor.execute_api(
ctx=ctx,
api_type=api_type,
params=params,
github_url=github_url
)
return result
except Exception as e:
return {"error": f"API 실행 중 오류: {str(e)}"}
async def _process_stock_name(self, ctx: Context, params: Dict[str, Any]) -> Dict[str, Any]:
"""종목명/종목코드 자동 처리 (stock_name이 있으면 자동으로 pdno 변환)"""
try:
# 종목명으로 찾을 수 있는 파라미터들
stock_name_params = ["stock_name", "stock_name_kr", "korean_name", "company_name"]
# 파라미터에서 종목명/종목코드 찾기
search_value = None
for param_name in stock_name_params:
if param_name in params and params[param_name]:
search_value = params[param_name]
break
# 검색할 값이 없으면 그대로 반환
if not search_value:
return params
await ctx.info(f"검색값 발견: {search_value}, 자동 검색 시작")
# 종목명 또는 종목코드로 검색
result = await self._find_stock_by_name_or_code(ctx, search_value)
if result["found"]:
params["pdno"] = result["code"]
await ctx.info(f"종목번호 자동 찾기 성공: {search_value}{result['code']}")
# 원본 검색값 보존
params["_original_search_value"] = search_value
params["_resolved_stock_code"] = result["code"]
else:
await ctx.warning(f"종목을 찾을 수 없음: {search_value}")
# 종목을 찾지 못해도 원본 파라미터 유지
return params
except Exception as e:
await ctx.error(f"종목명 자동 처리 실패: {str(e)}")
return params
async def _find_stock_by_name_or_code(self, ctx: Context, search_value: str) -> Dict[str, Any]:
"""종목명 또는 종목코드로 종목번호 찾기"""
try:
# 검색어에서 띄어쓰기 제거
search_term = search_value.replace(" ", "")
# 데이터베이스 연결 확인
if not self.db.ensure_initialized():
return {"found": False, "message": "데이터베이스 초기화 실패"}
# 마스터 파일 업데이트 확인 (force_update=False로 필요시에만 업데이트)
try:
from module.plugin import MasterFileManager
master_file_manager = MasterFileManager(self.tool_name)
await master_file_manager.ensure_master_file_updated(ctx, force_update=False)
except Exception as e:
await ctx.warning(f"마스터 파일 업데이트 확인 중 오류: {str(e)}")
# DB 엔진
db_engine = self.db.get_by_name("master")
master_models = MasterFileManager.get_master_models_for_tool(self.tool_name)
if not master_models:
return {"found": False, "message": f"지원하지 않는 툴: {self.tool_name}"}
# 각 모델에서 우선순위별 검색
for model_class in master_models:
try:
# 1순위: 종목코드로 완전 매칭
code_results = db_engine.list(
model_class,
filters={"code": search_term},
limit=1
)
if code_results:
result = code_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "code_exact"
}
# 2순위: 종목명으로 완전 매칭
name_results = db_engine.list(
model_class,
filters={"name": search_term},
limit=1
)
if name_results:
result = name_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "name_exact"
}
# 3순위: 종목명으로 앞글자 매칭
prefix_results = db_engine.list(
model_class,
filters={"name": f"{search_term}%"},
limit=1
)
if prefix_results:
result = prefix_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "name_prefix"
}
# 4순위: 종목명으로 중간 매칭
contains_results = db_engine.list(
model_class,
filters={"name": f"%{search_term}%"},
limit=1
)
if contains_results:
result = contains_results[0]
return {
"found": True,
"code": result.code,
"name": result.name,
"ex": result.ex if hasattr(result, 'ex') else None,
"match_type": "name_contains"
}
except Exception as e:
continue
return {"found": False, "message": f"종목을 찾을 수 없음: {search_value}"}
except Exception as e:
return {"found": False, "message": f"종목 검색 오류: {str(e)}"}
def get_api_info(self, api_type: str) -> Dict[str, Any]:
"""API 정보 조회 (리소스 기능 통합)"""
try:
# API 설정 조회
if api_type not in self.config['apis']:
return {
"error": f"지원하지 않는 API 타입: {api_type}",
"available_apis": list(self.config['apis'].keys()),
"api_type": api_type
}
# API 정보 반환
api_info = self.config['apis'][api_type]
# 파라미터 정보 정리
params = api_info.get("params", {})
param_details = {}
for param_name, param_info in params.items():
param_details[param_name] = {
"name": param_info.get("name", param_name),
"type": param_info.get("type", "str"),
"required": param_info.get("required", False),
"default_value": param_info.get("default_value"),
"description": param_info.get("description", "")
}
result = {
"tool_name": self.tool_name,
"api_type": api_type,
"name": api_info.get("name", ""),
"category_detail": api_info.get("category", ""),
"method": api_info.get("method", ""),
"api_path": api_info.get("api_path", ""),
"github_url": api_info.get("github_url", ""),
"params": param_details
}
return result
except Exception as e:
return {
"error": f"API 정보 조회 중 오류 발생: {str(e)}",
"tool_name": self.tool_name,
"api_type": api_type
}
async def _handle_find_stock_code(self, ctx: Context, params: Dict[str, Any]) -> Dict[str, Any]:
"""종목 검색 처리"""
try:
await ctx.info(f"종목 검색 요청: {self.tool_name}")
# stock_name 파라미터 확인
search_value = params.get("stock_name")
if not search_value:
return {
"ok": False,
"error": "MISSING_OR_INVALID_ARGS",
"missing": ["stock_name"],
"message": "stock_name 파라미터가 필요합니다. (종목명 또는 종목코드 입력 가능)"
}
# 종목 검색 실행 (종목명 또는 종목코드)
result = await self._find_stock_by_name_or_code(ctx, search_value)
if result["found"]:
return {
"ok": True,
"data": {
"tool_name": self.tool_name,
"search_value": search_value,
"found": True,
"stock_code": result["code"],
"stock_name_found": result["name"],
"ex": result.get("ex"),
"match_type": result.get("match_type"),
"message": f"'{search_value}' 종목을 찾았습니다. 종목번호: {result['code']}",
"usage_guide": f"find_api_detail로 API상세정보를 확인하고 종목코드 '{result['code']}'를 해당 API의 종목코드 필드에 입력하여 실행하세요.",
"next_step": f"{self.tool_name} 툴에서 find_api_detail로 확인한 종목코드 필드에 '{result['code']}'를 입력하세요."
}
}
else:
return {
"ok": False,
"error": "STOCK_NOT_FOUND",
"message": f"'{search_value}' 종목을 찾을 수 없습니다.",
"suggestions": [
"종목명의 철자가 정확한지 확인",
"종목코드가 정확한지 확인",
"띄어쓰기나 특수문자가 있는지 확인",
"다른 검색어로 시도 (예: '삼성전자' 대신 '삼성' 또는 '005930')"
]
}
except Exception as e:
await ctx.error(f"종목 검색 처리 중 오류: {str(e)}")
return {"ok": False, "error": str(e)}
async def _handle_find_api_detail(self, ctx: Context, params: Dict[str, Any]) -> Dict[str, Any]:
"""API 상세 정보 조회 처리"""
try:
await ctx.info(f"API 상세 정보 조회 요청: {self.tool_name}")
# api_type 파라미터 확인
target_api_type = params.get("api_type")
if not target_api_type:
return {
"ok": False,
"error": "MISSING_OR_INVALID_ARGS",
"missing": ["api_type"],
"message": "api_type 파라미터가 필요합니다.",
"available_apis": list(self.config['apis'].keys())
}
# API 정보 조회
api_info = self.get_api_info(target_api_type)
if "error" in api_info:
return {
"ok": False,
"error": api_info["error"],
"available_apis": api_info.get("available_apis", [])
}
return {
"ok": True,
"data": api_info
}
except Exception as e:
await ctx.error(f"API 상세 정보 조회 처리 중 오류: {str(e)}")
return {"ok": False, "error": str(e)}

View File

@@ -0,0 +1,10 @@
from .base import BaseTool
from module import singleton
@singleton
class DomesticBondTool(BaseTool):
@property
def tool_name(self) -> str:
return "domestic_bond"

View File

@@ -0,0 +1,15 @@
from .base import BaseTool
from module import singleton
import pandas as pd
import urllib.request
import ssl
import zipfile
import os
@singleton
class DomesticFutureOptionTool(BaseTool):
@property
def tool_name(self) -> str:
return "domestic_futureoption"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class DomesticStockTool(BaseTool):
@property
def tool_name(self) -> str:
return "domestic_stock"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class ElwTool(BaseTool):
@property
def tool_name(self) -> str:
return "elw"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class EtfEtnTool(BaseTool):
@property
def tool_name(self) -> str:
return "etfetn"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class OverseasFutureOptionTool(BaseTool):
@property
def tool_name(self) -> str:
return "overseas_futureoption"

View File

@@ -0,0 +1,9 @@
from .base import BaseTool
from module import singleton
@singleton
class OverseasStockTool(BaseTool):
@property
def tool_name(self) -> str:
return "overseas_stock"

View File

@@ -0,0 +1,383 @@
# MCP를 AI 도구에 연결하는 방법
### 한국투자증권 Open API를 활용하는 KIS Trade MCP와 KIS Code Assistant MCP를 AI 도구(Claude Desktop | Cursor)에 연결하는 설정 방법을 단계별로 안내합니다.
---
# 공통사항
한국투자증권 계좌와 한국투자증권 OpenAPI 홈페이지에서 인증정보(App Key, App Secret)를 준비해 주세요.
개발 환경 : Python 3.13 이상 권장
Claude Desktop 또는 Cursor와 같은 한국투자증권 MCP를 연결할 AI 도구를 설치해 주세요.
## **KIS Open API 신청 및 설정**
1. 한국투자증권 **계좌 개설 및 ID 연결**
2. 한국투자증권 홈페이지 or 앱에서 **Open API 서비스 신청**
3. **앱키(App Key)**, **앱시크릿(App Secret)** 발급
4. **모의투자** 및 **실전투자** 앱키 각각 준비
🍀 [서비스 신청 안내 바로가기](https://apiportal.koreainvestment.com/about-howto)
# 🔗 MCP(Model Context Protocol)란?
MCP는 Claude를 개발한 Anthropic에서 만든 프로토콜로, AI 모델이 외부 도구와 데이터에 안전하고 효율적으로 접근할 수 있게 해주는 표준화된 인터페이스입니다.
이제 한국투자증권이 만든 2개의 MCP를 통해 한국투자증권 Open API를 자연어로 쉽게 활용할 수 있습니다.
# 한국투자증권 MCP 소개
## KIS Trade MCP
### **특징 및 용도**
국내/해외주식, 선물·옵션, 채권, ETF/ETN, 인증 등 한국투자증권의 다양한 Open API를 **MCP 서버의 "도구"**로 래핑하였습니다. LLM이 바로 사용할 수 있도록 *API 스키마·파라미터*를 리소스로 제공하고, *모의/실전 환경*을 구분하여 안전하게 실행합니다.
### 설정 방법
(9월 중 공개 예정)
## KIS Code Assistant MCP
### 특징 및 용도
한국투자증권의 많은 Open API 중에서 **자연어 검색으로 관련 API를 찾고**, **호출 예제(파라미터 포함)까지 자동 구성**해주는 MCP 서버입니다. "무엇을 하고 싶은지"만 말하면, 관련 API를 추천하고 예시 호출 코드를 만들어 드립니다.
### 설정 방법
1. Claude Desktop
Link : [https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp](https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp)
<img width="2048" height="958" alt="image" src="https://github.com/user-attachments/assets/82aa8bc4-b112-482c-8e8d-34c41fb0ed76" />
<img width="2048" height="816" alt="image 1" src="https://github.com/user-attachments/assets/3404acc4-058a-4b41-a4d4-0d5aa62ddd3b" />
**AUTO / Claude Desktop** 선택 → Terminal 명령어 Copy 클릭
<img width="2048" height="884" alt="image 2" src="https://github.com/user-attachments/assets/a5852435-baa9-4fe0-a5e6-41929552b900" />
터미널에 명령어 붙여넣기하고 엔터 → 설치 완료 메시지 후 Claude 재시작 질문에는 Y 입력 후 엔터를 누르면 Claude Desktop 재시작
<img width="2048" height="1000" alt="image 3" src="https://github.com/user-attachments/assets/911b7818-bedf-4d04-8721-09cc4cf5409d" />
홈 화면 대화창 하단 **검색 및 도구** 버튼에서 설치 및 추가 확인 가능, `설정 → 개발자`에서도 확인 할 수 있습니다.
2. Cursor
Link : [https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp](https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp)
<img width="2048" height="988" alt="image 4" src="https://github.com/user-attachments/assets/5058bc1d-8046-47e4-9962-f7f1a5f3bcba" />
<img width="2048" height="988" alt="image 5" src="https://github.com/user-attachments/assets/6bb863b7-a8de-4435-8bdd-ef1deece02f0" />
**AUTO / Cursor** 선택 → **One-Click Install** 클릭
<img width="2048" height="958" alt="image 6" src="https://github.com/user-attachments/assets/f3e2f17b-f1b6-4b8f-a388-2990ef6f2a0e" />
Cursor에서 **Install** 클릭하면 완료
<img width="2048" height="958" alt="image 7" src="https://github.com/user-attachments/assets/a4fcdcdc-d83b-4187-946d-28160d7f65bf" />
KIS Code Assistant MCP가 연결되었는지 확인 (경로 : `Settings` > `MCP Servers`)
# 🚀 MCP기반 트레이딩 시스템 개발을 위한 환경 설정
트레이딩 시스템 개발을 시작하기 전에 필요한 Python 환경 구성부터 API 연결 테스트까지 개발 환경 설정 과정을 안내합니다.
### 1. 폴더 생성 및 파일 다운로드
트레이딩 시스템 개발을 위해 필요한 파일을 다운로드하고 폴더를 생성하고 경로를 지정하세요.
### **1-1. 보안 폴더 생성**
중요 정보를 저장하는 폴더와 실행 코드를 저장하는 폴더를 각각 생성합니다.
**맥/리눅스**:
```bash
mkdir -p ~/KIS/config
cd ~/KIS/config
```
**윈도우 PowerShell**:
```powershell
mkdir "$HOME\KIS\config"
cd "$HOME\KIS\config"
```
### **1-2. 프로젝트 폴더 생성**
**맥/리눅스**:
```bash
mkdir -p ~/자동매매
cd ~/자동매매
```
**윈도우 PowerShell**:
```powershell
mkdir "$HOME\자동매매"
cd "$HOME\자동매매"
```
### **1-3. GitHub에서 파일 다운로드**
한국투자증권 GitHub에서 세개 파일을 다운로드 받으세요.
**GitHub 링크**: https://github.com/koreainvestment/open-trading-api
1. **kis_devlp.yaml**`~/kis/config` 폴더에 저장 **(보안 정보로 별도 관리)**
https://github.com/koreainvestment/open-trading-api/blob/main/kis_devlp.yaml
2. **kis_auth.py**`~/자동매매/` 폴더에 저장
https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/kis_auth.py
3. **pyproject.toml**`~/자동매매/` 폴더에 저장
https://github.com/koreainvestment/open-trading-api/blob/main/pyproject.toml
> 경로 표기 안내
문서에서 `~`는 **내 사용자 폴더(홈)**를 뜻합니다.
`~/{폴더명}`은 그 안의 `{폴더명}` 폴더라는 의미이며, 실제 입력은 `~/kis/config`처럼 중괄호 없이 적습니다.
(Windows PowerShell: `~``C:\Users\내이름`)
>
### **1-4. `중요`kis_devlp.yaml 설정**
`~/KIS/kis_devlp.yaml` 파일에 발급받은 App key, App Secret, 계좌정보 (실전, 모의)를 입력하세요
```yaml
#홈페이지에서 API서비스 신청시 발급 AppKey, AppSecret 값 설정
#실전투자
my_app: "발급받은_실제_APP_KEY" # 한국투자증권에서 발급받은 APP KEY 입력
my_sec: "발급받은_실제_APP_SECRET" # 한국투자증권에서 발급받은 APP SECRET 입력
#모의투자
paper_app: "발급받은_실제_APP_KEY" # 모의투자용 APP KEY (실전과 동일)
paper_sec: "발급받은_실제_APP_SECRET" # 모의투자용 APP SECRET (실전과 동일)
# HTS ID
my_htsid: "실제_HTS_ID" # 한국투자증권 HTS ID 입력
#계좌번호 및 8자리
my_acct_stock: "실제_계좌번호" # 주식 계좌번호 (예: 50068418)
my_acct_future: "실제_계좌번호" # 선물옵션 계좌번호 (주식과 동일 가능)
my_paper_stock: "모의투자_계좌번호" # 모의투자 주식 계좌번호
my_paper_future: "모의투자_계좌번호" # 모의투자 선물옵션 계좌번호 (주식과 동일 가능)
#계좌번호 뒤 2자리
my_prod: "01" # 01(종합계좌), 03(국내선물옵션), 08(해외선물옵션), 22(개인연금), 29(퇴직연금)
```
> **⚠️ 보안 주의사항:**
>
> - App Key/App Secret과 계좌번호는 절대 타인과 공유하지 마세요.
> - GitHub 공개 저장소와 같이 외부에 공개된 저장소에는 절대 업로드하지 마세요.
> - 트레이딩 시스템 폴더와 별도의 경로(~/KIS/config)에 보관하세요.
> - 정기적으로 API Key를 재발급하여 보안을 강화하세요
## 2. uv 설치 및 가상환경 설정
### **2-1. uv 설치**
**맥/리눅스**:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**윈도우**:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
### **2-2. 가상환경 설정**
프로젝트 폴더로 이동 후 가상환경 생성:
```bash
cd ~/자동매매
uv sync
```
### **2-3. 가상환경 활성화**
**맥/리눅스**:
```bash
source .venv/bin/activate
```
**윈도우**:
```bash
.venv\Scripts\activate
```
## 3. 연결 테스트 (필수 검증)
### 3-1. 기본 연결 테스트
`~/자동매매/test_connection.py` 파일을 생성하고 해당 코드를 복사/붙여넣기 합니다.
(모의투자로 세팅되어 있습니다.)
```python
# test_connection.py
# KIS Open API 연결 테스트 및 기본 정보 확인 스크립트
import sys
import os
try:
from kis_auth import auth, getTREnv, getEnv, read_token
import kis_auth
# 설정 파일 확인
print("설정 파일 확인 중...")
cfg = getEnv()
print(f"앱키: {cfg.get('my_app', 'None')[:10]}...")
print(f"서버 URL: {cfg.get('prod', 'None')}")
# 인증 토큰 발급 테스트
print("토큰 발급 시도 중...")
try:
# 디버그 모드 활성화
kis_auth._DEBUG = True
auth(svr="vps") # 모의투자 토큰 발급 및 저장
print("토큰 발급 완료")
# 토큰이 제대로 설정되지 않은 경우 수동으로 설정
env = getTREnv()
if not env.my_token:
print("토큰이 환경에 설정되지 않음. 저장된 토큰을 확인합니다...")
saved_token = read_token()
if saved_token:
print("저장된 토큰을 찾았습니다. 환경에 설정합니다...")
# 토큰을 직접 설정
kis_auth._TRENV = kis_auth._TRENV._replace(my_token=saved_token)
kis_auth._base_headers["authorization"] = f"Bearer {saved_token}"
print("토큰 설정 완료")
else:
print("저장된 토큰도 없습니다.")
except Exception as auth_error:
print(f"토큰 발급 중 오류: {auth_error}")
import traceback
traceback.print_exc()
# 환경 정보 확인
env = getTREnv()
if hasattr(env, 'my_token') and env.my_token:
print("✅ API 연결 성공!")
print(f"토큰 앞 10자리: {env.my_token[:10]}...")
print(f"계좌번호: {env.my_acct}")
print(f"서버: {'모의투자' if env.my_url.find('vts') > 0 else '실전투자'}")
else:
print("❌ API 연결 실패 - 토큰이 없습니다")
print(f"토큰 속성 존재: {hasattr(env, 'my_token')}")
if hasattr(env, 'my_token'):
print(f"토큰 값: {env.my_token}")
print(f"토큰 길이: {len(env.my_token) if env.my_token else 0}")
except Exception as e:
print(f"❌ 오류 발생: {e}")
print("devlp.yaml 파일 경로와 설정을 확인해주세요")
```
### 3-2. 테스트 실행
테스트를 실행하고 결과를 확인하세요.
```bash
# 실행
cd ~/자동매매
python test_connection.py
# 결과
✅ API 연결 성공!
토큰 앞 10자리: asdfasdfas...
계좌번호: 12345678
서버: 모의투자
```
### 🛠️ 자주 발생하는 문제와 해결방법
1. MCP 연결 실패 시
- Claude Desktop/Cursor 재시작
- MCP 서버 URL 확인 ([https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp](https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp))
- 방화벽 설정 확인
- 인터넷 연결 확인
2. API 연결 오류 시
- App Key와 Secret이 발급 받은 것과 동일한지 확인
- kis_auth.py 의 내용이 다운로드 받은 파일과 동일한지 확인
- kis_devlp.yaml 파일이 “~/KIS/config/” 혹은 “$HOME/KIS/config”에 있는지 확인
- kis_devlp.yaml 파일에 작성한 개인정보가 정확한지 확인 (App Key/Secret, HTS ID, 계좌번호, 상품코드)
- kis_devlp.yaml 파일의 문법이 올바른지 확인 (YAML 문법, 들여쓰기 주의)
3. 가상환경 문제 시
- uv 버전 확인: `uv --version`
- pyproject.toml 의 내용이 다운로드 받은 파일과 동일한지 확인
- 프로그램 실행에 필요한 전체 패키지 재설치: `uv sync`
- 가상환경 재생성: `uv venv --force`
4. Python 모듈 import 오류 시
- 가상환경 활성화 확인
- 필요 패키지 설치: `uv add {패키지명}`
---
## 4. 최종 폴더 구조 확인
설정이 성공적으로 완료되면 폴더 구조는 다음과 같습니다.
```
~/KIS/
└── config/
└── devlp.yaml (보안 정보)
~/자동매매/
├── kis_auth.py
├── pyproject.toml
├── test_connection.py
├── .venv/ (uv sync 후 자동 생성)
└── uv.lock (uv sync 후 자동 생성)
```
---
## Next Step
설정이 완료되셨다면 이제 투자를 위한 전략를 구현하세요.
1. 🎯 MCP를 활용하여 개발 시작하기
- Cursor에서 KIS Code Assistant MCP를 활용하여 자동매매 시스템 개발
- 자연어로 '주식 현재가 조회 코드 보여줘' 같은 질문하기
2. 📊 모의투자 환경에서 충분한 테스트 진행
- 실제 거래 전 반드시 모의투자로 검증
- 손절/익절 로직 구현 및 테스트
3. 🔒 실전 투자 적용 시 보안과 리스크 관리 강화
- 포트폴리오 분산 투자 권장
- 정기적인 API 키 교체
🚀 고급 활용 팁
- 백테스팅을 통한 전략 검증
- 실시간 알림 시스템 구축
- 리스크 관리 자동화
---
한국투자증권은 기술을 통해 투자의 진입장벽을 낮추고, 투자자들이 더 나은 투자 경험을 할 수 있도록 MCP를 통해 복잡한 API 연동 등 개발환경을 개선하여 투자 전략 본질에 집중할 수 있도록 지원합니다.
AI와 함께하는 새로운 투자 시대, 여러분만의 성공 투자 스토리에 한국투자증권 MCP가 든든한 파트너가 되겠습니다.

View File

@@ -0,0 +1,78 @@
# 한국투자증권 MCP: 투자 코딩이 쉬워지는 순간
## 🚀 소개
한국투자증권에서 개발자들을 위한 혁신적인 도구를 공개합니다. **MCP(Model Context Protocol)**를 활용해 AI 모델이 증권 데이터에 직접 접근할 수 있는 두 가지 도구를 제공합니다:
1. **KIS Code Assistant MCP**: 한국투자증권 OpenAPI 사용법과 샘플코드를 AI 도구가 검색/제공
2. **KIS Trading MCP** : 한국투자증권 OpenAPI를 AI 도구에서 직접 호출
## 🔧 지원 기능
### KIS Code Assistant MCP
https://smithery.ai/server/@KISOpenAPI/kis-code-assistant-mcp
- **스마트 검색**: 자연어로 원하는 API 기능 검색
- **샘플코드 제공**: GitHub에서 실제 구현 코드 자동 검색
- **카테고리별 탐색**: 기능별 체계적인 API 분류
### KIS Trading MCP
- **시세 조회**: 국내/해외 주식, 선물/옵션, 채권, ETF/ELW
- **계좌 관리**: 잔고조회, 주문/체결내역, 손익현황
- **매매 주문**: 현물/신용/선물옵션 주문 및 정정취소
- **시장 분석**: 순위정보, 투자자별 매매동향, 프로그램매매
## 💡 활용 예시
### Claude와 함께 사용
```
사용자: "삼성전자 현재가와 호가창 정보 알려줘"
Claude: [KIS OpenAPI] → 실시간 데이터 제공
사용자: "내 계좌 잔고에서 수익률 높은 종목 5개 보여줘"
Claude: [잔고 조회 + 데이터 분석] → 맞춤형 포트폴리오 분석
```
### Cursor IDE에서 활용
```python
# AI가 자동으로 API 호출 코드 생성 및 실행
def get_stock_info(code):
"""삼성전자 주가 정보를 가져오는 함수를 만들어줘"""
# MCP를 통해 실제 API 호출 코드가 자동 생성됨
```
## 🌟 특징
- **데이터**: 지연 없는 시세 정보
- **완전한 API 커버리지**: 한투 OpenAPI의 모든 기능 지원
- **자연어 인터페이스**: 복잡한 API 문서 없이 대화로 사용
- **코드 자동 생성**: 샘플코드를 기반으로 한 맞춤형 구현
- **보안**: OAuth 토큰 기반 안전한 인증
## 🎯 사용 시나리오
### 개인 투자자
- "오늘 상승률 상위 10개 종목의 PER, PBR 비교해줘"
- "내 포트폴리오에서 손절매가 필요한 종목 찾아줘"
### 퀀트 개발자
- "볼린저밴드 돌파 전략으로 백테스팅 코드 만들어줘"
- "RSI와 MACD 조합 신호로 매매 로직 구현해줘"
### 핀테크 개발사
- "고객별 맞춤 포트폴리오 추천 시스템 개발"
- "실시간 리스크 모니터링 대시보드 구축"
## 📈 왜 MCP인가?
기존 REST API의 한계를 뛰어넘어, AI가 상황에 맞는 최적의 API를 선택하고 호출합니다. 개발자는 복잡한 API 문서를 읽을 필요 없이 자연어로 원하는 기능을 요청하면 됩니다.
---
*개발 지식이 있는 투자자라면 이제 아이디어만 있으면 됩니다. 복잡한 구현은 AI가, 실제 매매는 한투 API가 담당합니다. 더 스마트한 투자를 위한 새로운 도구를 경험해 보세요.*
---

View File

@@ -0,0 +1,314 @@
**[당사에서 제공하는 샘플코드에 대한 유의사항]**
- 샘플 코드는 한국투자증권 Open API(KIS Developers)를 연동하는 예시입니다. 고객님의 개발 부담을 줄이고자 참고용으로 제공되고 있습니다.
- 샘플 코드는 별도의 공지 없이 지속적으로 업데이트될 수 있습니다.
- 샘플 코드를 활용하여 제작한 고객님의 프로그램으로 인한 손해에 대해서는 당사에서 책임지지 않습니다.
# KIS Open API 샘플 코드 저장소 (LLM 지원)
## 1. 제작 의도 및 대상
### 🎯 제작 의도
이 저장소는 **ChatGPT, Claude 등 LLM(Large Language Model)** 기반 자동화 환경과 Python 개발자 모두가
**한국투자증권(Korea Investment & Securities) Open API를 쉽게 이해하고 활용**할 수 있도록 구성된 샘플 코드 모음입니다.
- `examples_llm/`: LLM이 단일 API 기능을 쉽게 탐색하고 호출할 수 있도록 구성된 기능 단위 샘플 코드
- `examples_user/`: 사용자가 실제 투자 및 자동매매 구현에 활용할 수 있도록 상품별로 통합된 API 호출 예제 코드
> AI와 사람이 모두 활용하기 쉬운 구조를 지향합니다.
[한국투자증권 Open API 포털 바로가기](https://apiportal.koreainvestment.com/)
### 👤 대상 사용자
- 한국투자증권 Open API를 처음 사용하는 Python 개발자
- 기존 Open API 사용자 중 코드 개선 및 구조 학습이 필요한 사용자
- LLM 기반 코드 에이전트를 활용해 종목 검색, 시세 분석, 자동매매 등을 구현하고자 하는 사용자
## 2. 폴더 구조 및 주요 파일 설명
### 2.1. 폴더 구조
```
# 프로젝트 구조
.
├── README.md # 프로젝트 설명서
├── docs/
│ └── convention.md # 코딩 컨벤션 가이드
├── examples_llm/ # LLM용 샘플 코드
│ ├── kis_auth.py # 인증 공통 함수
│ ├── domestic_bond # 국내채권
│ │ └── inquire_price # API 단일 기능별 폴더
│ │ ├── inquire_price.py # 한줄 호출 파일 (예: 채권 가격 조회)
│ │ └── chk_inquire_price.py # 테스트 파일 (예: 채권 가격 조회 결과 검증)
│ ├── domestic_futureoption # 국내선물옵션
│ ├── domestic_stock # 국내주식
│ ├── elw # ELW
│ ├── etfetn # ETF/ETN
│ ├── overseas_futureoption # 해외선물옵션
│ ├── overseas_price # 해외시세
│ └── overseas_stock # 해외주식
├── examples_user/ # user용 실제 사용 예제
│ ├── kis_auth.py # 인증 공통 함수
│ ├── domestic_bond # 국내채권
│ │ ├── domestic_bond_functions.py # (REST) 통합 함수 파일 (모든 API 함수 모음)
│ │ ├── domestic_bond_examples.py # (REST) 실행 예제 파일 (함수 사용법)
│ │ ├── domestic_bond_functions_ws.py # (Websocket) 통합 함수 파일
│ │ └── domestic_bond_examples_ws.py # (Websocket) 실행 예제 파일
│ ├── domestic_futureoption # 국내선물옵션
│ ├── domestic_stock # 국내주식
│ ├── elw # ELW
│ ├── etfetn # ETF/ETN
│ ├── overseas_futureoption # 해외선물옵션
│ ├── overseas_price # 해외시세
│ └── overseas_stock # 해외주식
├── legacy/ # 구 샘플코드 보관
├── stock_info/ # 종목정보파일 참고 데이터
├── kis_devlp.yaml # API 설정 파일 (개인정보 입력 필요)
├── pyproject.toml # (uv)프로젝트 의존성 관리
└── uv.lock # (uv)의존성 락 파일
```
### 2.2. 지원되는 주요 API 카테고리
- 아래 카테고리 및 폴더 구조는 examples_llm/, examples_user/ 폴더 모두 동일하게 적용됩니다.
| 카테고리 | 설명 | 폴더명 |
| --- | --- | --- |
| 국내주식 | 국내 주식 시세, 주문, 잔고 등 | `domestic_stock` |
| 국내채권 | 국내 채권 시세, 주문 등 | `domestic_bond` |
| 국내선물옵션 | 국내 파생상품 관련 | `domestic_futureoption` |
| 해외주식 | 해외 주식 시세, 주문 등 | `overseas_stock` |
| 해외선물옵션 | 해외 파생상품 관련 | `overseas_futureoption` |
| ELW | ELW 시세 API | `elw` |
| ETF/ETN | ETF, ETN 시세 API | `etfetn` |
### 2.3. 주요 파일 설명
### `examples_llm/` - llm용 기능 단위 샘플 코드
**API별 개별 폴더 구조**: 단일 API 기능을 독립 폴더로 분리하여, LLM이 관련 코드를 쉽게 탐색할 수 있도록 구성
- **한줄 호출 파일**: `[함수명].py` 단일 기능을 호출하는 최소 단위 코드 (예: `inquire_price.py`)
- **테스트 파일**: `chk_[함수명].py` 호출 결과를 검증하는 테스트 실행 코드 (예: `chk_inquire_price.py`)
### `examples_user/` - 사용자용 통합 예제 코드
**카테고리별 개별 폴더 구조**: 카테고리(상품)별로 모든 기능을 통합하여, 사용자가 쉽게 샘플 코드를 탐색하고 실행할 수 있도록 구성
- **통합 함수 파일**: `[카테고리]_functions.py` - 해당 카테고리의 모든 API 기능이 통합된 함수 모음
- **실행 예제 파일**: `[카테고리]_examples.py` - 실제 사용 예제를 기반으로 한 실행 코드
- **웹소켓 통합 함수 파일 및 실행 예제 파일**: `[카테고리]_functions_ws.py`, `[카테고리]_examples_ws.py`
### `kis_auth.py` - 인증 및 공통 기능
- 접근토큰 발급 및 관리
- API 호출 공통 함수
- 실전투자/모의투자 환경 전환 지원
- 웹소켓 연결 설정 기능 제공
## 3. 사전 환경설정 안내
### 3.1. Python 환경 요구사항
- **Python 3.9 이상** 필요
- **uv** **패키지 매니저 사용** 권장 (빠르고 간편한 의존성 관리)
### 3.2. uv 설치 방법
- 간편 설정을 위해 uv를 권장합니다
```bash
# Windows (PowerShell)
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# 설치 확인
uv --version
# uv 0.x.x ... -> 설치 완료
```
### 3.3. 프로젝트 클론 및 환경 설정
```bash
# 저장소 클론
git clone https://github.com/koreainvestment/open-trading-api
cd open-trading-api/kis_github
# uv를 사용한 의존성 설치 - 한줄로 끝
uv sync
```
### 3.4. KIS Open API 신청 및 설정
🍀 [서비스 신청 안내 바로가기](https://apiportal.koreainvestment.com/about-howto)
1. 한국투자증권 **계좌 개설 및 ID 연결**
2. 한국투자증권 홈페이지 or 앱에서 **Open API 서비스 신청**
3. **앱키(App Key)**, **앱시크릿(App Secret)** 발급
4. **모의투자****실전투자** 앱키 각각 준비
### 3.5. kis_devlp.yaml 설정
- 본인의 계정 설정을 위해 `kis_devlp.yaml` 파일을 열어 다음과 같이 수정합니다.
1. **프로젝트 루트에 위치한** `kis_devlp.yaml` 파일 열기
2. **앱키와 앱시크릿** 정보 입력
3. **HTS ID** 정보 입력
4. **계좌번호** 정보 입력 (앞 8자리와 뒤 2자리 구분)
5. **저장** 후 닫기
```yaml
# 실전투자
my_app: "여기에 실전투자 앱키 입력"
my_sec: "여기에 실전투자 앱시크릿 입력"
# 모의투자
paper_app: "여기에 모의투자 앱키 입력"
paper_sec: "여기에 모의투자 앱시크릿 입력"
# HTS ID(KIS Developers 고객 ID) - 체결통보, 나의 조건 목록 확인 등에 사용됩니다.
my_htsid: "사용자 HTS ID"
# 계좌번호 앞 8자리
my_acct_stock: "증권계좌 8자리"
my_acct_future: "선물옵션계좌 8자리"
my_paper_stock: "모의투자 증권계좌 8자리"
my_paper_future: "모의투자 선물옵션계좌 8자리"
# 계좌번호 뒤 2자리
my_prod: "01" # 종합계좌
# my_prod: "03" # 국내선물옵션 계좌
# my_prod: "08" # 해외선물옵션 계좌
# my_prod: "22" # 연금저축 계좌
# my_prod: "29" # 퇴직연금 계좌
# User-Agent(기본값 사용 권장, 변경 불필요)
my_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
```
### 3.6. kis_auth.py 설정 경로 수정
- `kis_auth.py`의 config_root 경로를 본인 환경에 맞게 수정해줍니다. 발급된 토큰 파일이 저장될 경로로, 제3자가 찾기 어렵도록 설정하는것을 권장합니다.
```yaml
# kis_auth.py 39번째 줄
# windows - C:\Users\사용자이름\KIS\config
# Linux/macOS - /home/사용자이름/KIS/config
# config_root = os.path.join(os.path.expanduser("~"), "KIS", "config")
config_root = os.path.join(os.path.expanduser("~"), "폴더 경로", "config")
```
### 3.7. 실행파일 내 인증 설정 검토
- 실행하려는 파일에서 인증 관련 설정을 검토 혹은 변경해줍니다. 국내주식 기능 전체를 이용하시려면, `domestic_stock/domestic_stock_examples.py` 파일을 확인해주세요.
ka.auth() 함수의 svr, product 매개변수를 아래와 같이 수정하면 실전환경(prod)에서 위탁계좌(-01)로 매매 테스트가 가능합니다.
```python
import kis_auth as ka
# 실전투자 인증
ka.auth(svr="prod", product="01") # 모의투자: svr="vps"
```
## 4. 샘플 코드 실행
### 4.1. 샘플 코드 실행
- **examples_user 기준**
```bash
# 국내주식 샘플 코드 실행 (examples_user/domestic_stock/)
python domestic_stock_examples.py # REST 방식
python domestic_stock_examples_ws.py # Websocket 방식
```
domestic_stock_examples.py에는 여러 함수가 포함되어 있으므로, 사용하려는 함수만 남기고 나머지는 주석 처리한 후, 입력값을 수정하여 호출해 주세요.
- **examples_llm 기준**
```bash
# 국내주식 > 주식현재가 시세 샘플 코드 실행 (examples_llm/domestic_stock/inquire_price/)
python chk_inquire_price.py
```
examples_llm 은 각 기능별로 개별 실행 파일(chk_*.py)이 분리되어 있어, 특정 기능만 테스트하고자 할 때 유용합니다.
### 4.2. 예제 코드 샘플 (examples_user)
```python
# REST API 호출 예제 - domestic_stock_examples.py
import sys
import logging
import pandas as pd
sys.path.extend(['..', '.'])
import kis_auth as ka
from domestic_stock_functions import *
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# 인증
ka.auth()
trenv = ka.getTREnv()
# 삼성전자 현재가 시세 조회
result = inquire_price(env_dv="real", fid_cond_mrkt_div_code="J", fid_input_iscd="005930")
print(result)
```
```python
# 웹소켓 호출 예제 - domestic_stock_examples_ws.py
import sys
import logging
import pandas as pd
sys.path.extend(['..', '.'])
import kis_auth as ka
from domestic_stock_functions_ws import *
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# 인증
ka.auth()
ka.auth_ws()
trenv = ka.getTREnv()
# 웹소켓 선언
kws = ka.KISWebSocket(api_url="/tryitout")
# 삼성전자, sk하이닉스 실시간 호가 구독
kws.subscribe(request=asking_price_krx, data=["005930", "000660"])
```
## 5. 문제 해결 가이드
### 토큰 오류 시
```python
import kis_auth as ka
# 토큰 재발급 - 1분당 1회 발급됩니다.
ka.auth(svr="prod") # 또는 "vps"
```
### 설정 파일 오류 시
- `kis_devlp.yaml` 파일의 앱키, 앱시크릿이 올바른지 확인
- 계좌번호 형식이 맞는지 확인 (앞 8자리 + 뒤 2자리)
- 실시간 시세(WebSocket) 이용 중 No close frame received 오류가 발생하는 경우, `kis_devlp.yaml`에 입력하신 HTS ID가 정확한지 확인
### 의존성 오류 시
```bash
# 의존성 재설치
uv sync --reinstall
```
---
# 📧 문의사항
- [💬 한국투자증권 Open API 챗봇](https://chatgpt.com/g/g-68b920ee7afc8191858d3dc05d429571-hangugtujajeunggweon-open-api-seobiseu-gpts)에 언제든 궁금한 점을 물어보세요.

View File

@@ -0,0 +1,112 @@
# 한국투자증권 OpenAPI 코드 컨벤션
## 정의
- 한줄호출함수: 사용자 코드에 import 해서 한줄로 API를 실행할 수 있도록 만들어 주는 함수들이 담긴 파일이다
- 체크함수: 한줄호출함수 파일을 import하여, API를 실행한 후 결과 값을 출력하는 파일을 테스트 파일
## 단어에 대한 정의
- 1개의 용어에 대해서는 1개의 단어를 사용함으로써, 통일성을 유지하고, LLM이 혼란스럽지 않도록 사용한다.
- 동음이의어, 이음동의어와 같은 문맥상 파악이 필요한 단어를 최대한 지양한다.
## 네이밍 컨벤션
- 네이밍 파이썬 기본 규칙에 벗어나지 않으며, 역할과 의미가 명확해야 한다.
- 널리 알려진 축약어( URL,ID 등) 외에는 축약어와 모호한 이름은 사용하지 않는다.
- 형식
- 모듈 : snake_case
- 변수: snake_case
- 함수 : snake_case
- 클래스 : PascalCase
- 상수 : UPPER_SNAKE_CASE
## 폴더 명명 규칙
- API 주소에서 차용하여 생성
```text
url: /uapi/domestic-stock/v1/quotations/comp-program-trade-daily
폴더이름: domestic_stock
```
- 웹소켓은 URL 이 없으므로, 홈페이의 분류명에 맞추어 적용
## 파일 명명 규칙
1. Rest API 한줄호출함수 파일 명명 규칙
- 규칙 : API 주소에서 차용
- 예시
```text
url: /uapi/domestic-stock/v1/quotations/comp-program-trade-daily
폴더이름: comp_program_trade_daily
파일이름: comp_program_trade_daily.py
```
2. Websocket 한줄호출함수 파일 명명 규칙
- 규칙 : 웹소켓 함수 직역과 함께, 유사한 이름의 RestAPI를 참고하여 생성
3. 테스트 파일 명명 규칙
- 규칙 : "chk_한줄호출함수 파일이름.py"
- 예시
```text
한줄호출함수 파일이름: comp_program_trade_daily.py
체크함수 : chk_comp_program_trade_daily.py
```
## Docstring 작성
- 코드 블록의 목적, 인자, 반환 값, 예외등을 상세히 기술
- Google, Sphinx, NumPy 등 널리 사용되는 Docstring 형식을 따라야 한다.
- 예제 코드를 포함해야 한다.
## 주석
- 파일 상단에 모듈 전체의 목적과 주요 기능, 다른 모듈과의 관계, 주요 구성 요소와 그들간의 상호작용 방식을 설명하는 주석 필요 (인코딩, 작성 시각과 작성자)
- 자연어를 기반으로 학습되므로, 가급적 완전한 문장 형태의 자연스러운 설명을 사용 할것 (축약되거나 암호를 사용하지 말것)
- 코드 자체는 “무엇”을 하는지 자체로 명확해야하며, 주석은 “왜” 그렇게 작성했는지를 적어야함 (복잡한 로직, 비직관적인 해결 방법, 특정 디자인 결정의 배경 등)
- 함수/클래스 : 해당 코드 블록이 무엇을 하는지 설명
- 매개변수 : 이름,타입, 역할, 필수 여부, 기본 값 등을 상세 기술
- 반환 값 : 무엇을 반환하는지, 타입
- 예외처리 : 어떤 상황에서 어떤 예외가 발생할 수 있는지 명시
- 사용예시 : 실제 코드를 사용하여, 어떻게 사용하는지를 보여주는 예시
- 사전/사후 조건 : 함수 실행 전후에 보장되어야 하는 조건을 명시
- Input, Ouput 파라미터에 대체 가능한 옵션들에 대한 설명을 추가
- 파라미터는 Request Header/Query/Body, Response Header/Body 총 5개로 구성되어 있음
- 각각을 Class화하고 타입을 명시적으로 선언, 필수값은 일반 타입이지만, 선택 값은 Optional을 사용
- 코드가 변경되면 주석을 변경
- TODO, FIXME 와 같은 태그를 활용하여 개선 혹은 수정할 내용을 명시
## 코드의 모듈화 및 단일 책임 원칙
- 함수와 클래스는 가능한 한 작고, 하나의 명확한 기능만을 갖도록 설계
- 재사용성을 함께 제공하면서, LLM이 독립적으로 이해하는 데 도움이 된다.
- 긴 함수/클래스는 지양한다.
- 추상화 계층이나, 복잡한 디자인 패턴은 LLM이 이해하기 어렵게 만들 수 있으므로, 직관적인 코드를 넣을 것
- 장황하더라도 명확한 코드가 좋음 (한줄 마법은 지양)
## 설정 관리
- API 키, 경로, 임계 값등 자주 변경되거나, 환경에 따라 달라지는 설정은 코드에서 분리
- .env
- config.py
- etc..
## import 정리
- Wildcard는 지양하며, 필요한 것만 명시적으로 import 할 것
- 라이브러리 from에 따라 그룹화하고, 알파벳순으로 정렬
- 라이브러리 import 순서
1) 표준 라이브러리
2) 써드파티
3) 로컬 어플리케이션/Lib
## 변수 선언
- 변수의 타입을 명시적으로 선언한다.
- 함수의 파라미터, 리턴값, 변수의 타입을 명시적으로 선언해야 한다.
- 복잡한 타입 (List, Dict, tuple, Optional, Union, Callable 등)을 활용하여 데이터 구조도를 명확하게 표현한다.
## 에러 처리
- try-except 블록을 사용하되, 구체적인 예외 타입을 명시할 것
- 중요한 이벤트, 에러, 상태 변경 등을 “logging” 을 사용하여 기록 할 것

View File

@@ -0,0 +1,120 @@
# [인증] OAuth 접근토큰 발급
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import json
import logging
import sys
import pandas as pd
import requests
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] OAuth 접근토큰 발급
##############################################################################################
# 상수 정의
API_URL = "/oauth2/tokenP"
def auth_token(
grant_type: str,
appkey: str,
appsecret: str,
env_dv: str
) -> pd.DataFrame:
"""
OAuth 접근토큰 발급 API를 호출하여 DataFrame으로 반환합니다.
Args:
grant_type (str): [필수] 권한부여 Type (client_credentials)
appkey (str): [필수] 앱키 (한국투자증권 홈페이지에서 발급받은 appkey)
appsecret (str): [필수] 앱시크릿키 (한국투자증권 홈페이지에서 발급받은 appsecret)
env_dv (str): [필수] 환경구분 (real: 실전, demo: 모의)
Returns:
pd.DataFrame: OAuth 토큰 발급 결과
Example:
>>> df = auth_token(
... grant_type="client_credentials",
... appkey=trenv.my_app,
... appsecret=trenv.my_sec,
... env_dv="real"
... )
>>> print(df)
"""
# 필수 파라미터 검증
if not grant_type:
logger.error("grant_type is required. (e.g. 'client_credentials')")
raise ValueError("grant_type is required. (e.g. 'client_credentials')")
if not appkey:
logger.error("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
raise ValueError("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
if not appsecret:
logger.error("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
raise ValueError("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
if not env_dv:
logger.error("env_dv is required. (real: 실전, demo: 모의)")
raise ValueError("env_dv is required. (real: 실전, demo: 모의)")
# 환경 구분에 따른 서버 URL 설정
config = ka.getEnv()
if env_dv == "real":
base_url = config.get("prod", "")
elif env_dv == "demo":
base_url = config.get("vps", "")
else:
logger.error("env_dv must be 'real' or 'demo'")
raise ValueError("env_dv must be 'real' or 'demo'")
url = f"{base_url}{API_URL}"
# 헤더 설정
headers = {
"Content-Type": "application/json",
"Accept": "text/plain",
"charset": "UTF-8"
}
# 요청 데이터
data = {
"grant_type": grant_type,
"appkey": appkey,
"appsecret": appsecret,
}
try:
# POST 방식으로 직접 API 호출
response = requests.post(url, data=json.dumps(data), headers=headers)
if response.status_code == 200:
# 응답 데이터를 DataFrame으로 반환 (1개 row)
response_data = response.json()
current_data = pd.DataFrame([response_data])
logger.info("OAuth 토큰 발급 성공")
return current_data
else:
logger.error("API call failed: %s - %s", response.status_code, response.text)
return pd.DataFrame()
except requests.RequestException as e:
logger.error("Request failed: %s", str(e))
return pd.DataFrame()
except json.JSONDecodeError as e:
logger.error("JSON decode failed: %s", str(e))
return pd.DataFrame()

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from auth_token import auth_token
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] OAuth 접근토큰 발급 테스트
##############################################################################################
# 통합 컬럼 매핑
COLUMN_MAPPING = {
'access_token': '접근토큰',
'token_type': '접근토큰유형',
'expires_in': '접근토큰유효기간_초',
'access_token_token_expired': '접근토큰유효기간_일시표시'
}
def main():
"""
OAuth 접근토큰 발급 테스트 함수
Parameters:
- grant_type (str): 권한부여 Type (client_credentials)
- appkey (str): 앱키 (한국투자증권 홈페이지에서 발급받은 appkey)
- appsecret (str): 앱시크릿키 (한국투자증권 홈페이지에서 발급받은 appsecret)
- env_dv (str): 환경구분 (real: 실전, demo: 모의)
Returns:
- pd.DataFrame: OAuth 토큰 발급 결과
Response Fields:
- access_token: 접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)
- token_type: 접근토큰유형 ("Bearer")
- expires_in: 접근토큰 유효기간(초)
- access_token_token_expired: 접근토큰 유효기간(일시표시)
Example:
>>> df = auth_token(grant_type="client_credentials", appkey=trenv.my_app, appsecret=trenv.my_sec, env_dv="real")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 환경 설정에서 앱키와 앱시크릿 가져오기
config = ka.getEnv()
# 실전투자용 앱키/앱시크릿 사용 (모의투자의 경우 paper_app, paper_sec 사용)
appkey = config.get("my_app", "")
appsecret = config.get("my_sec", "")
# 앱키와 앱시크릿이 설정되어 있는지 확인
if not appkey or not appsecret:
logger.error("앱키 또는 앱시크릿이 설정되지 않았습니다. kis_devlp.yaml 파일을 확인해주세요.")
logger.info("필요한 설정: my_app (앱키), my_sec (앱시크릿)")
return
# API 호출
logger.info("OAuth 접근토큰 발급 API 호출 시작")
result = auth_token(
grant_type="client_credentials",
appkey=appkey,
appsecret=appsecret,
env_dv="real" # 실전 환경으로 설정 (필요시 "demo"로 변경)
)
# 결과 확인
if result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 결과 처리
logger.info("=== OAuth 접근토큰 발급 결과 ===")
logger.info("사용 가능한 컬럼: %s", result.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result = result.rename(columns=COLUMN_MAPPING)
logger.info("결과:")
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,127 @@
# [인증] WebSocket 웹소켓 접속키 발급
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import json
import logging
import sys
from typing import Optional
import pandas as pd
import requests
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] WebSocket 웹소켓 접속키 발급
##############################################################################################
# 상수 정의
API_URL = "/oauth2/Approval"
def auth_ws_token(
grant_type: str,
appkey: str,
appsecret: str,
env_dv: str,
token: Optional[str] = ""
) -> pd.DataFrame:
"""
WebSocket 웹소켓 접속키 발급 API를 호출하여 DataFrame으로 반환합니다.
Args:
grant_type (str): [필수] 권한부여 Type (client_credentials)
appkey (str): [필수] 고객 앱Key (한국투자증권 홈페이지에서 발급받은 appkey)
appsecret (str): [필수] 고객 앱Secret (한국투자증권 홈페이지에서 발급받은 appsecret)
env_dv (str): [필수] 환경구분 (real: 실전, demo: 모의)
token (Optional[str]): 접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)
Returns:
pd.DataFrame: WebSocket 접속키 발급 결과
Example:
>>> df = auth_ws_token(
... grant_type="client_credentials",
... appkey=trenv.my_app,
... appsecret=trenv.my_sec,
... env_dv="real"
... )
>>> print(df)
"""
# 필수 파라미터 검증
if not grant_type:
logger.error("grant_type is required. (e.g. 'client_credentials')")
raise ValueError("grant_type is required. (e.g. 'client_credentials')")
if not appkey:
logger.error("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
raise ValueError("appkey is required. (한국투자증권 홈페이지에서 발급받은 appkey)")
if not appsecret:
logger.error("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
raise ValueError("appsecret is required. (한국투자증권 홈페이지에서 발급받은 appsecret)")
if not env_dv:
logger.error("env_dv is required. (real: 실전, demo: 모의)")
raise ValueError("env_dv is required. (real: 실전, demo: 모의)")
# 환경 구분에 따른 서버 URL 설정
config = ka.getEnv()
if env_dv == "real":
base_url = config.get("prod", "")
elif env_dv == "demo":
base_url = config.get("vps", "")
else:
logger.error("env_dv must be 'real' or 'demo'")
raise ValueError("env_dv must be 'real' or 'demo'")
url = f"{base_url}{API_URL}"
# 헤더 설정
headers = {
"Content-Type": "application/json",
"Accept": "text/plain",
"charset": "UTF-8"
}
# 요청 데이터
data = {
"grant_type": grant_type,
"appkey": appkey,
"secretkey": appsecret,
}
# token이 있는 경우에만 data에 추가
if token:
data["token"] = token
try:
# POST 방식으로 직접 API 호출
response = requests.post(url, data=json.dumps(data), headers=headers)
if response.status_code == 200:
# 응답 데이터를 DataFrame으로 반환 (1개 row)
response_data = response.json()
current_data = pd.DataFrame([response_data])
logger.info("WebSocket 접속키 발급 성공")
return current_data
else:
logger.error("API call failed: %s - %s", response.status_code, response.text)
return pd.DataFrame()
except requests.RequestException as e:
logger.error("Request failed: %s", str(e))
return pd.DataFrame()
except json.JSONDecodeError as e:
logger.error("JSON decode failed: %s", str(e))
return pd.DataFrame()

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from auth_ws_token import auth_ws_token
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [인증] WebSocket 웹소켓 접속키 발급 테스트
##############################################################################################
# 통합 컬럼 매핑
COLUMN_MAPPING = {
'code': '응답코드',
'message': '응답메세지',
'approval_key': '웹소켓접속키'
}
def main():
"""
WebSocket 웹소켓 접속키 발급 테스트 함수
Parameters:
- grant_type (str): 권한부여 Type (client_credentials)
- appkey (str): 고객 앱Key (한국투자증권 홈페이지에서 발급받은 appkey)
- appsecret (str): 고객 앱Secret (한국투자증권 홈페이지에서 발급받은 appsecret)
- env_dv (str): 환경구분 (real: 실전, demo: 모의)
- token (str): 접근토큰 (OAuth 토큰이 필요한 API 경우 발급한 Access token)
Returns:
- pd.DataFrame: WebSocket 접속키 발급 결과
Response Fields:
- code: 응답코드 (HTTP 응답코드)
- message: 응답메세지
Example:
>>> df = auth_ws_token(grant_type="client_credentials", appkey=trenv.my_app, appsecret=trenv.my_sec, env_dv="real")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# OAuth 토큰 발급 (WebSocket 접속키 발급에 필요)
logger.info("OAuth 토큰 발급 중...")
ka.auth() # 토큰 발급
logger.info("OAuth 토큰 발급 완료")
# 환경 설정에서 토큰, 앱키, 앱시크릿 가져오기
trenv = ka.getTREnv()
appkey = trenv.my_app
appsecret = trenv.my_sec
# 토큰 및 앱키가 설정되어 있는지 확인
if not appkey or not appsecret:
logger.error("앱키 또는 앱시크릿이 설정되지 않았습니다.")
return
# API 호출
logger.info("WebSocket 웹소켓 접속키 발급 API 호출 시작")
result = auth_ws_token(
grant_type="client_credentials",
appkey=appkey,
appsecret=appsecret,
env_dv="real", # 실전 환경으로 설정 (필요시 "demo"로 변경)
)
# 결과 확인
if result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 결과 처리
logger.info("=== WebSocket 웹소켓 접속키 발급 결과 ===")
logger.info("사용 가능한 컬럼: %s", result.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result = result.rename(columns=COLUMN_MAPPING)
logger.info("결과:")
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,171 @@
# [장내채권] 기본시세 - 장내채권 평균단가조회
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import logging
import time
import sys
from typing import Optional, Tuple
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 기본시세 > 장내채권 평균단가조회 [국내채권-158]
##############################################################################################
# 상수 정의
API_URL = "/uapi/domestic-bond/v1/quotations/avg-unit"
def avg_unit(
inqr_strt_dt: str, # 조회시작일자
inqr_end_dt: str, # 조회종료일자
pdno: str, # 상품번호
prdt_type_cd: str, # 상품유형코드
vrfc_kind_cd: str, # 검증종류코드
NK30: str = "", # 연속조회키30
FK100: str = "", # 연속조회검색조건100
dataframe1: Optional[pd.DataFrame] = None, # 누적 데이터프레임 (output1)
dataframe2: Optional[pd.DataFrame] = None, # 누적 데이터프레임 (output2)
dataframe3: Optional[pd.DataFrame] = None, # 누적 데이터프레임 (output3)
tr_cont: str = "",
depth: int = 0,
max_depth: int = 10
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""
[장내채권] 기본시세
장내채권 평균단가조회[국내주식-158]
장내채권 평균단가조회 API를 호출하여 DataFrame으로 반환합니다.
Args:
inqr_strt_dt (str): 조회 시작 일자 (예: '20230101')
inqr_end_dt (str): 조회 종료 일자 (예: '20230131')
pdno (str): 상품번호, 공백: 전체, 특정종목 조회시 : 종목코드
prdt_type_cd (str): 상품유형코드 (예: '302')
vrfc_kind_cd (str): 검증종류코드 (예: '00')
NK30 (str): 연속조회키30, 공백 허용
FK100 (str): 연속조회검색조건100, 공백 허용
dataframe1 (Optional[pd.DataFrame]): 누적 데이터프레임 (output1)
dataframe2 (Optional[pd.DataFrame]): 누적 데이터프레임 (output2)
dataframe3 (Optional[pd.DataFrame]): 누적 데이터프레임 (output3)
tr_cont (str): 연속 거래 여부
depth (int): 현재 재귀 깊이
max_depth (int): 최대 재귀 깊이 (기본값: 10)
Returns:
Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: 장내채권 평균단가조회 데이터
Example:
>>> df1, df2, df3 = avg_unit(
... inqr_strt_dt='20230101',
... inqr_end_dt='20230131',
... pdno='KR2033022D33',
... prdt_type_cd='302',
... vrfc_kind_cd='00',
... )
>>> print(df1)
>>> print(df2)
>>> print(df3)
"""
# 필수 파라미터 검증
if not inqr_strt_dt:
logger.error("inqr_strt_dt is required. (e.g. '20230101')")
raise ValueError("inqr_strt_dt is required. (e.g. '20230101')")
if not inqr_end_dt:
logger.error("inqr_end_dt is required. (e.g. '20230131')")
raise ValueError("inqr_end_dt is required. (e.g. '20230131')")
if not prdt_type_cd:
logger.error("prdt_type_cd is required. (e.g. '302')")
raise ValueError("prdt_type_cd is required. (e.g. '302')")
if not vrfc_kind_cd:
logger.error("vrfc_kind_cd is required. (e.g. '00')")
raise ValueError("vrfc_kind_cd is required. (e.g. '00')")
# 최대 재귀 깊이 체크
if depth >= max_depth:
logger.warning("Maximum recursion depth (%d) reached. Stopping further requests.", max_depth)
return (
dataframe1 if dataframe1 is not None else pd.DataFrame(),
dataframe2 if dataframe2 is not None else pd.DataFrame(),
dataframe3 if dataframe3 is not None else pd.DataFrame()
)
tr_id = "CTPF2005R"
params = {
"INQR_STRT_DT": inqr_strt_dt,
"INQR_END_DT": inqr_end_dt,
"PDNO": pdno,
"PRDT_TYPE_CD": prdt_type_cd,
"VRFC_KIND_CD": vrfc_kind_cd,
"CTX_AREA_NK30": NK30,
"CTX_AREA_FK100": FK100,
}
res = ka._url_fetch(API_URL, tr_id, tr_cont, params)
if res.isOK():
# 연속조회 정보 업데이트
tr_cont = res.getHeader().tr_cont
NK30 = res.getBody().ctx_area_nk30
FK100 = res.getBody().ctx_area_fk100
# output1 데이터 처리
current_data1 = pd.DataFrame(res.getBody().output1)
if dataframe1 is not None:
dataframe1 = pd.concat([dataframe1, current_data1], ignore_index=True)
else:
dataframe1 = current_data1
# output2 데이터 처리
current_data2 = pd.DataFrame(res.getBody().output2)
if dataframe2 is not None:
dataframe2 = pd.concat([dataframe2, current_data2], ignore_index=True)
else:
dataframe2 = current_data2
# output3 데이터 처리
current_data3 = pd.DataFrame(res.getBody().output3)
if dataframe3 is not None:
dataframe3 = pd.concat([dataframe3, current_data3], ignore_index=True)
else:
dataframe3 = current_data3
if tr_cont in ["M", "F"]: # 다음 페이지 존재
logger.info("Call Next page...")
ka.smart_sleep() # 시스템 안정적 운영을 위한 지연
return avg_unit(
inqr_strt_dt,
inqr_end_dt,
pdno,
prdt_type_cd,
vrfc_kind_cd,
NK30,
FK100,
dataframe1,
dataframe2,
dataframe3,
"N",
depth + 1,
max_depth
)
else:
logger.info("Data fetch complete.")
return dataframe1, dataframe2, dataframe3
else:
logger.error("API call failed: %s - %s", res.getErrorCode(), res.getErrorMessage())
res.printError(API_URL)
return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()

View File

@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from avg_unit import avg_unit
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 기본시세 > 장내채권 평균단가조회 [국내채권-158]
##############################################################################################
# 통합 컬럼 매핑 (모든 output에서 공통 사용)
COLUMN_MAPPING = {
'evlu_dt': '평가일자',
'pdno': '상품번호',
'prdt_type_cd': '상품유형코드',
'kis_unpr': '한국신용평가단가',
'kbp_unpr': '한국채권평가단가',
'nice_evlu_unpr': '한국신용정보평가단가',
'fnp_unpr': '에프앤자산평가단가',
'avg_evlu_unpr': '평균평가단가',
'kis_crdt_grad_text': '한국신용평가신용등급내용',
'kbp_crdt_grad_text': '한국채권평가신용등급내용',
'nice_crdt_grad_text': '한국신용정보신용등급내용',
'fnp_crdt_grad_text': '에프앤자산평가신용등급내용',
'chng_yn': '변경여부',
'kis_erng_rt': '한국신용평가수익율',
'kbp_erng_rt': '한국채권평가수익율',
'nice_evlu_erng_rt': '한국신용정보평가수익율',
'fnp_erng_rt': '에프앤자산평가수익율',
'avg_evlu_erng_rt': '평균평가수익율',
'kis_rf_unpr': '한국신용평가RF단가',
'kbp_rf_unpr': '한국채권평가RF단가',
'nice_evlu_rf_unpr': '한국신용정보평가RF단가',
'avg_evlu_rf_unpr': '평균평가RF단가',
'evlu_dt': '평가일자',
'pdno': '상품번호',
'prdt_type_cd': '상품유형코드',
'kis_evlu_amt': '한국신용평가평가금액',
'kbp_evlu_amt': '한국채권평가평가금액',
'nice_evlu_amt': '한국신용정보평가금액',
'fnp_evlu_amt': '에프앤자산평가평가금액',
'avg_evlu_amt': '평균평가금액',
'chng_yn': '변경여부',
'output3': '응답상세',
'evlu_dt': '평가일자',
'pdno': '상품번호',
'prdt_type_cd': '상품유형코드',
'kis_crcy_cd': '한국신용평가통화코드',
'kis_evlu_unit_pric': '한국신용평가평가단위가격',
'kis_evlu_pric': '한국신용평가평가가격',
'kbp_crcy_cd': '한국채권평가통화코드',
'kbp_evlu_unit_pric': '한국채권평가평가단위가격',
'kbp_evlu_pric': '한국채권평가평가가격',
'nice_crcy_cd': '한국신용정보통화코드',
'nice_evlu_unit_pric': '한국신용정보평가단위가격',
'nice_evlu_pric': '한국신용정보평가가격',
'avg_evlu_unit_pric': '평균평가단위가격',
'avg_evlu_pric': '평균평가가격',
'chng_yn': '변경여부'
}
NUMERIC_COLUMNS = []
def main():
"""
[장내채권] 기본시세
장내채권 평균단가조회[국내주식-158]
장내채권 평균단가조회 테스트 함수`
Parameters:
- inqr_strt_dt (str): 조회시작일자 (일자 ~)
- inqr_end_dt (str): 조회종료일자 (~ 일자)
- pdno (str): 상품번호 (공백: 전체, 특정종목 조회시 : 종목코드)
- prdt_type_cd (str): 상품유형코드 (Unique key(302))
- vrfc_kind_cd (str): 검증종류코드 (Unique key(00))
Returns:
- Tuple[DataFrame, ...]: 장내채권 평균단가조회 결과
Example:
>>> df1, df2, df3 = avg_unit(inqr_strt_dt="20250101", inqr_end_dt="20250131", pdno="KR2033022D33", prdt_type_cd="302", vrfc_kind_cd="00")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 토큰 발급
logger.info("토큰 발급 중...")
ka.auth()
logger.info("토큰 발급 완료")
# API 호출
logger.info("API 호출 시작: 장내채권 평균단가조회")
result1, result2, result3 = avg_unit(
inqr_strt_dt="20240101", # 조회시작일자
inqr_end_dt="20250630", # 조회종료일자
pdno="KR103502GA34", # 상품번호
prdt_type_cd="302", # 상품유형코드
vrfc_kind_cd="00", # 검증종류코드
)
# 결과 확인
results = [result1, result2, result3]
if all(result is None or result.empty for result in results):
logger.warning("조회된 데이터가 없습니다.")
return
# output1 결과 처리
logger.info("=== output1 조회 ===")
if not result1.empty:
logger.info("사용 가능한 컬럼: %s", result1.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result1 = result1.rename(columns=COLUMN_MAPPING)
for col in NUMERIC_COLUMNS:
if col in result1.columns:
result1[col] = pd.to_numeric(result1[col], errors='coerce').round(2)
logger.info("output1 결과:")
print(result1)
else:
logger.info("output1 데이터가 없습니다.")
# output2 결과 처리
logger.info("=== output2 조회 ===")
if not result2.empty:
logger.info("사용 가능한 컬럼: %s", result2.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result2 = result2.rename(columns=COLUMN_MAPPING)
for col in NUMERIC_COLUMNS:
if col in result2.columns:
result2[col] = pd.to_numeric(result2[col], errors='coerce').round(2)
logger.info("output2 결과:")
print(result2)
else:
logger.info("output2 데이터가 없습니다.")
# output3 결과 처리
logger.info("=== output3 조회 ===")
if not result3.empty:
logger.info("사용 가능한 컬럼: %s", result3.columns.tolist())
# 통합 컬럼명 한글 변환 (필요한 컬럼만 자동 매핑됨)
result3 = result3.rename(columns=COLUMN_MAPPING)
for col in NUMERIC_COLUMNS:
if col in result3.columns:
result3[col] = pd.to_numeric(result3[col], errors='coerce').round(2)
logger.info("output3 결과:")
print(result3)
else:
logger.info("output3 데이터가 없습니다.")
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,97 @@
"""
Created on 2025-07-09
"""
import logging
import sys
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간호가 [실시간-053]
##############################################################################################
def bond_asking_price(
tr_type: str,
tr_key: str,
) -> tuple[dict, list[str]]:
"""
일반채권 실시간호가[H0BJASP0]
일반채권 실시간호가 API를 통해 실시간 데이터를 구독합니다.
Args:
tr_type (str): [필수] 구독 등록("1") 또는 해제("0") 여부를 나타냅니다.
tr_key (str): [필수] 종목코드. 빈 문자열일 수 없습니다.
Returns:
message (dict): 실시간 데이터 메시지.
columns (list[str]): 응답 데이터의 컬럼 정보.
Raises:
ValueError: tr_key가 빈 문자열인 경우 발생합니다.
Example:
>>> msg, columns = bond_asking_price("1", "005930")
>>> print(msg, columns)
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
"""
# 필수 파라미터 검증
if not tr_key:
raise ValueError("tr_key is required and cannot be an empty string")
tr_id = "H0BJASP0"
params = {
"tr_key": tr_key,
}
# 데이터 요청
msg = ka.data_fetch(tr_id, tr_type, params)
# 응답 데이터 컬럼 정보
columns = [
"stnd_iscd",
"stck_cntg_hour",
"askp_ert1",
"bidp_ert1",
"askp1",
"bidp1",
"askp_rsqn1",
"bidp_rsqn1",
"askp_ert2",
"bidp_ert2",
"askp2",
"bidp2",
"askp_rsqn2",
"bidp_rsqn2",
"askp_ert3",
"bidp_ert3",
"askp3",
"bidp3",
"askp_rsqn3",
"bidp_rsqn3",
"askp_ert4",
"bidp_ert4",
"askp4",
"bidp4",
"askp_rsqn4",
"bidp_rsqn4",
"askp_ert5",
"bidp_ert5",
"askp5",
"bidp5",
"askp_rsqn52",
"bidp_rsqn53",
"total_askp_rsqn",
"total_bidp_rsqn",
]
return msg, columns

View File

@@ -0,0 +1,133 @@
"""
Created on 2025-07-09
"""
import logging
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
from bond_asking_price import bond_asking_price
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간호가 [실시간-053]
##############################################################################################
COLUMN_MAPPING = {
"stnd_iscd": "표준종목코드",
"stck_cntg_hour": "주식체결시간",
"askp_ert1": "매도호가수익률1",
"bidp_ert1": "매수호가수익률1",
"askp1": "매도호가1",
"bidp1": "매수호가1",
"askp_rsqn1": "매도호가잔량1",
"bidp_rsqn1": "매수호가잔량1",
"askp_ert2": "매도호가수익률2",
"bidp_ert2": "매수호가수익률2",
"askp2": "매도호가2",
"bidp2": "매수호가2",
"askp_rsqn2": "매도호가잔량2",
"bidp_rsqn2": "매수호가잔량2",
"askp_ert3": "매도호가수익률3",
"bidp_ert3": "매수호가수익률3",
"askp3": "매도호가3",
"bidp3": "매수호가3",
"askp_rsqn3": "매도호가잔량3",
"bidp_rsqn3": "매수호가잔량3",
"askp_ert4": "매도호가수익률4",
"bidp_ert4": "매수호가수익률4",
"askp4": "매도호가4",
"bidp4": "매수호가4",
"askp_rsqn4": "매도호가잔량4",
"bidp_rsqn4": "매수호가잔량4",
"askp_ert5": "매도호가수익률5",
"bidp_ert5": "매수호가수익률5",
"askp5": "매도호가5",
"bidp5": "매수호가5",
"askp_rsqn52": "매도호가잔량5",
"bidp_rsqn53": "매수호가잔량5",
"total_askp_rsqn": "총매도호가잔량",
"total_bidp_rsqn": "총매수호가잔량"
}
NUMERIC_COLUMNS = [
"매도호가수익률1", "매수호가수익률1", "매도호가1", "매수호가1", "매도호가잔량1", "매수호가잔량1",
"매도호가수익률2", "매수호가수익률2", "매도호가2", "매수호가2", "매도호가잔량2", "매수호가잔량2",
"매도호가수익률3", "매수호가수익률3", "매도호가3", "매수호가3", "매도호가잔량3", "매수호가잔량3",
"매도호가수익률4", "매수호가수익률4", "매도호가4", "매수호가4", "매도호가잔량4", "매수호가잔량4",
"매도호가수익률5", "매수호가수익률5", "매도호가5", "매수호가5", "매도호가잔량5", "매수호가잔량5",
"총매도호가잔량", "총매수호가잔량"
]
def main():
"""
일반채권 실시간호가
일반채권 실시간호가 API입니다.
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
[호출 데이터]
헤더와 바디 값을 합쳐 JSON 형태로 전송합니다.
[응답 데이터]
1. 정상 등록 여부 (JSON)
- JSON["body"]["msg1"] - 정상 응답 시, SUBSCRIBE SUCCESS
- JSON["body"]["output"]["iv"] - 실시간 결과 복호화에 필요한 AES256 IV (Initialize Vector)
- JSON["body"]["output"]["key"] - 실시간 결과 복호화에 필요한 AES256 Key
2. 실시간 결과 응답 ( | 로 구분되는 값)
ex) 0|H0STCNT0|004|005930^123929^73100^5^...
- 암호화 유무 : 0 암호화 되지 않은 데이터 / 1 암호화된 데이터
- TR_ID : 등록한 tr_id (ex. H0STCNT0)
- 데이터 건수 : (ex. 001 인 경우 데이터 건수 1건, 004인 경우 데이터 건수 4건)
- 응답 데이터 : 아래 response 데이터 참조 ( ^로 구분됨)
"""
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 인증 토큰 발급
ka.auth()
ka.auth_ws()
# 인증(auth_ws()) 이후에 선언
kws = ka.KISWebSocket(api_url="/tryitout")
# 조회
kws.subscribe(request=bond_asking_price, data=["KR103502GA34", "KR6095572D81"])
# 결과 표시
def on_result(ws, tr_id: str, result: pd.DataFrame, data_map: dict):
try:
# 컬럼 매핑
result.rename(columns=COLUMN_MAPPING, inplace=True)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
logging.info("결과:")
print(result)
except Exception as e:
logging.error(f"결과 처리 중 오류: {e}")
logging.error(f"받은 데이터: {result}")
kws.start(on_result=on_result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,85 @@
"""
Created on 2025-07-09
"""
import logging
import sys
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간체결가 [실시간-052]
##############################################################################################
def bond_ccnl(
tr_type: str,
tr_key: str,
) -> tuple[dict, list[str]]:
"""
일반채권 실시간체결가[H0BJCNT0] 구독 함수
한국투자증권 웹소켓 API를 통해 일반채권의 실시간 체결가 데이터를 구독합니다.
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
Args:
tr_type (str): [필수] 구독 등록("1") 또는 해제("0") 여부
tr_key (str): [필수] 종목코드 (빈 문자열 불가)
Returns:
message (dict): 실시간 데이터 구독 결과 메시지
columns (list[str]): 응답 데이터의 컬럼 정보
Raises:
ValueError: tr_key가 빈 문자열인 경우 발생
Example:
>>> msg, columns = bond_ccnl("1", "005930")
>>> print(msg, columns)
"""
# 필수 파라미터 검증
if not tr_key:
raise ValueError("tr_key는 빈 문자열일 수 없습니다.")
tr_id = "H0BJCNT0"
params = {
"tr_key": tr_key,
}
# 데이터 구독 요청
msg = ka.data_fetch(tr_id, tr_type, params)
# 응답 데이터 컬럼 정보
columns = [
"stnd_iscd", # 표준종목코드
"bond_isnm", # 채권종목명
"stck_cntg_hour", # 주식체결시간
"prdy_vrss_sign", # 전일대비부호
"prdy_vrss", # 전일대비
"prdy_ctrt", # 전일대비율
"stck_prpr", # 현재가
"cntg_vol", # 체결거래량
"stck_oprc", # 시가
"stck_hgpr", # 고가
"stck_lwpr", # 저가
"stck_prdy_clpr", # 전일종가
"bond_cntg_ert", # 현재수익률
"oprc_ert", # 시가수익률
"hgpr_ert", # 고가수익률
"lwpr_ert", # 저가수익률
"acml_vol", # 누적거래량
"prdy_vol", # 전일거래량
"cntg_type_cls_code", # 체결유형코드
]
return msg, columns

View File

@@ -0,0 +1,112 @@
"""
Created on 2025-07-09
"""
import logging
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
from bond_ccnl import bond_ccnl
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 일반채권 실시간체결가 [실시간-052]
##############################################################################################
COLUMN_MAPPING = {
"stnd_iscd": "표준종목코드",
"bond_isnm": "채권종목명",
"stck_cntg_hour": "주식체결시간",
"prdy_vrss_sign": "전일대비부호",
"prdy_vrss": "전일대비",
"prdy_ctrt": "전일대비율",
"stck_prpr": "현재가",
"cntg_vol": "체결거래량",
"stck_oprc": "시가",
"stck_hgpr": "고가",
"stck_lwpr": "저가",
"stck_prdy_clpr": "전일종가",
"bond_cntg_ert": "현재수익률",
"oprc_ert": "시가수익률",
"hgpr_ert": "고가수익률",
"lwpr_ert": "저가수익률",
"acml_vol": "누적거래량",
"prdy_vol": "전일거래량",
"cntg_type_cls_code": "체결유형코드"
}
NUMERIC_COLUMNS = [
"전일대비", "전일대비율", "현재가", "체결거래량", "시가", "고가", "저가", "전일종가",
"현재수익률", "시가수익률", "고가수익률", "저가수익률", "누적거래량", "전일거래량"
]
def main():
"""
일반채권 실시간체결가
일반채권 실시간체결가 API입니다.
[호출 데이터]
헤더와 바디 값을 합쳐 JSON 형태로 전송합니다.
[응답 데이터]
1. 정상 등록 여부 (JSON)
- JSON["body"]["msg1"] - 정상 응답 시, SUBSCRIBE SUCCESS
- JSON["body"]["output"]["iv"] - 실시간 결과 복호화에 필요한 AES256 IV (Initialize Vector)
- JSON["body"]["output"]["key"] - 실시간 결과 복호화에 필요한 AES256 Key
2. 실시간 결과 응답 ( | 로 구분되는 값)
ex) 0|H0STCNT0|004|005930^123929^73100^5^...
- 암호화 유무 : 0 암호화 되지 않은 데이터 / 1 암호화된 데이터
- TR_ID : 등록한 tr_id (ex. H0STCNT0)
- 데이터 건수 : (ex. 001 인 경우 데이터 건수 1건, 004인 경우 데이터 건수 4건)
- 응답 데이터 : 아래 response 데이터 참조 ( ^로 구분됨)
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
"""
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 인증 토큰 발급
ka.auth()
ka.auth_ws()
# 인증(auth_ws()) 이후에 선언
kws = ka.KISWebSocket(api_url="/tryitout")
# 조회
kws.subscribe(request=bond_ccnl, data=["KR103502GA34", "KR6095572D81"])
# 결과 표시
def on_result(ws, tr_id: str, result: pd.DataFrame, data_map: dict):
try:
# 컬럼 매핑
result.rename(columns=COLUMN_MAPPING, inplace=True)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
logging.info("결과:")
print(result)
except Exception as e:
logging.error(f"결과 처리 중 오류: {e}")
logging.error(f"받은 데이터: {result}")
kws.start(on_result=on_result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,84 @@
"""
Created on 2025-07-09
"""
import logging
import sys
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 채권지수 실시간체결가 [실시간-060]
##############################################################################################
def bond_index_ccnl(
tr_type: str,
tr_key: str,
) -> tuple[dict, list[str]]:
"""
채권지수 실시간체결가[H0BICNT0]
채권지수 실시간체결가 API를 통해 실시간 데이터를 구독합니다.
Args:
tr_type (str): [필수] 구독 등록("1") 또는 해제("0") 여부를 나타냅니다.
tr_key (str): [필수] 구독할 종목코드. 빈 문자열이 아니어야 합니다.
Returns:
message (dict): 구독 요청에 대한 응답 메시지.
columns (list[str]): 실시간 데이터의 컬럼 정보.
Raises:
ValueError: tr_key가 빈 문자열인 경우 발생합니다.
Example:
>>> msg, columns = bond_index_ccnl("1", "005930")
>>> print(msg, columns)
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
"""
# 필수 파라미터 검증
if not tr_key:
raise ValueError("tr_key is required and cannot be an empty string")
tr_id = "H0BICNT0"
params = {
"tr_key": tr_key,
}
# 데이터 구독 요청
msg = ka.data_fetch(tr_id, tr_type, params)
# 응답 데이터 컬럼 정보
columns = [
"nmix_id", # 지수ID
"stnd_date1", # 기준일자1
"trnm_hour", # 전송시간
"totl_ernn_nmix_oprc", # 총수익지수시가지수
"totl_ernn_nmix_hgpr", # 총수익지수최고가
"totl_ernn_nmix_lwpr", # 총수익지수최저가
"totl_ernn_nmix", # 총수익지수
"prdy_totl_ernn_nmix", # 전일총수익지수
"totl_ernn_nmix_prdy_vrss", # 총수익지수전일대비
"totl_ernn_nmix_prdy_vrss_sign", # 총수익지수전일대비부호
"totl_ernn_nmix_prdy_ctrt", # 총수익지수전일대비율
"clen_prc_nmix", # 순가격지수
"mrkt_prc_nmix", # 시장가격지수
"bond_call_rnvs_nmix", # Call재투자지수
"bond_zero_rnvs_nmix", # Zero재투자지수
"bond_futs_thpr", # 선물이론가격
"bond_avrg_drtn_val", # 평균듀레이션
"bond_avrg_cnvx_val", # 평균컨벡서티
"bond_avrg_ytm_val", # 평균YTM
"bond_avrg_frdl_ytm_val", # 평균선도YTM
]
return msg, columns

View File

@@ -0,0 +1,124 @@
"""
Created on 2025-07-09
"""
import logging
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
from bond_index_ccnl import bond_index_ccnl
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 실시간시세 > 채권지수 실시간체결가 [실시간-060]
##############################################################################################
COLUMN_MAPPING = {
"nmix_id": "지수ID",
"stnd_date1": "기준일자1",
"trnm_hour": "전송시간",
"totl_ernn_nmix_oprc": "총수익지수시가지수",
"totl_ernn_nmix_hgpr": "총수익지수최고가",
"totl_ernn_nmix_lwpr": "총수익지수최저가",
"totl_ernn_nmix": "총수익지수",
"prdy_totl_ernn_nmix": "전일총수익지수",
"totl_ernn_nmix_prdy_vrss": "총수익지수전일대비",
"totl_ernn_nmix_prdy_vrss_sign": "총수익지수전일대비부호",
"totl_ernn_nmix_prdy_ctrt": "총수익지수전일대비율",
"clen_prc_nmix": "순가격지수",
"mrkt_prc_nmix": "시장가격지수",
"bond_call_rnvs_nmix": "Call재투자지수",
"bond_zero_rnvs_nmix": "Zero재투자지수",
"bond_futs_thpr": "선물이론가격",
"bond_avrg_drtn_val": "평균듀레이션",
"bond_avrg_cnvx_val": "평균컨벡서티",
"bond_avrg_ytm_val": "평균YTM",
"bond_avrg_frdl_ytm_val": "평균선도YTM"
}
NUMERIC_COLUMNS = [
"총수익지수시가지수", "총수익지수최고가", "총수익지수최저가", "총수익지수",
"전일총수익지수", "총수익지수전일대비", "총수익지수전일대비율", "순가격지수",
"시장가격지수", "Call재투자지수", "Zero재투자지수", "선물이론가격"]
def main():
"""
채권지수 실시간체결가
채권지수 실시간체결가 API입니다.
[참고자료]
채권 종목코드 마스터파일은 "KIS포털 - API문서 - 종목정보파일 - 장내채권 - 채권코드" 참고 부탁드립니다.
[호출 데이터]
헤더와 바디 값을 합쳐 JSON 형태로 전송합니다.
[응답 데이터]
1. 정상 등록 여부 (JSON)
- JSON["body"]["msg1"] - 정상 응답 시, SUBSCRIBE SUCCESS
- JSON["body"]["output"]["iv"] - 실시간 결과 복호화에 필요한 AES256 IV (Initialize Vector)
- JSON["body"]["output"]["key"] - 실시간 결과 복호화에 필요한 AES256 Key
2. 실시간 결과 응답 ( | 로 구분되는 값)
ex) 0|H0STCNT0|004|005930^123929^73100^5^...
- 암호화 유무 : 0 암호화 되지 않은 데이터 / 1 암호화된 데이터
- TR_ID : 등록한 tr_id (ex. H0STCNT0)
- 데이터 건수 : (ex. 001 인 경우 데이터 건수 1건, 004인 경우 데이터 건수 4건)
- 응답 데이터 : 아래 response 데이터 참조 ( ^로 구분됨)
"""
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 인증 토큰 발급
ka.auth()
ka.auth_ws()
# 인증(auth_ws()) 이후에 선언
kws = ka.KISWebSocket(api_url="/tryitout")
# 조회
# 한경채권지수: KBPR01, KBPR02, KBPR03, KBPR04
# KIS채권지수: KISR01, MSBI07, KTBL10, MSBI09, MSBI10, CDIX01
# 매경채권지수: MKFR01, MSBI01, MSBI03, MSBI10, CORP01
kws.subscribe(request=bond_index_ccnl, data=[
# 한경채권지수
"KBPR01", "KBPR02", "KBPR03", "KBPR04",
# KIS채권지수
"KISR01", "MSBI07", "KTBL10", "MSBI09", "MSBI10", "CDIX01",
# 매경채권지수
"MKFR01", "MSBI01", "MSBI03", "MSBI10", "CORP01"
])
# 결과 표시
def on_result(ws, tr_id: str, result: pd.DataFrame, data_map: dict):
try:
# 컬럼 매핑
result.rename(columns=COLUMN_MAPPING, inplace=True)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
logging.info("결과:")
print(result)
except Exception as e:
logging.error(f"결과 처리 중 오류: {e}")
logging.error(f"받은 데이터: {result}")
kws.start(on_result=on_result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,112 @@
# [장내채권] 주문/계좌 - 장내채권 매수주문
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-20
"""
import logging
from typing import Optional, Tuple
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 주문/계좌 > 장내채권 매수주문 [국내주식-124]
##############################################################################################
# 상수 정의
API_URL = "/uapi/domestic-bond/v1/trading/buy"
def buy(
cano: str,
acnt_prdt_cd: str,
pdno: str,
ord_qty2: str,
bond_ord_unpr: str,
samt_mket_ptci_yn: str,
bond_rtl_mket_yn: str,
idcr_stfno: str = "",
mgco_aptm_odno: str = "",
ord_svr_dvsn_cd: str = "",
ctac_tlno: str = ""
) -> Optional[pd.DataFrame]:
"""
[장내채권] 주문/계좌
장내채권 매수주문[국내주식-124]
장내채권 매수주문 API를 호출하여 DataFrame으로 반환합니다.
Args:
cano (str): 종합계좌번호 (8자리)
acnt_prdt_cd (str): 계좌상품코드 (2자리)
pdno (str): 상품번호 (12자리)
ord_qty2 (str): 주문수량2 (19자리)
bond_ord_unpr (str): 채권주문단가 (182자리)
samt_mket_ptci_yn (str): 소액시장참여여부 ('Y' or 'N')
bond_rtl_mket_yn (str): 채권소매시장여부 ('Y' or 'N')
idcr_stfno (str, optional): 유치자직원번호 (6자리). Defaults to "".
mgco_aptm_odno (str, optional): 운용사지정주문번호 (12자리). Defaults to "".
ord_svr_dvsn_cd (str, optional): 주문서버구분코드. Defaults to "".
ctac_tlno (str, optional): 연락전화번호. Defaults to "".
Returns:
Optional[pd.DataFrame]: 장내채권 매수주문 데이터
Example:
>>> df = buy(
... cano=trenv.my_acct,
... acnt_prdt_cd=trenv.my_prod,
... pdno="KR1234567890",
... ord_qty2="10",
... bond_ord_unpr="10000",
... samt_mket_ptci_yn="N",
... bond_rtl_mket_yn="Y"
... )
>>> print(df)
"""
tr_id = "TTTC0952U"
params = {
"CANO": cano,
"ACNT_PRDT_CD": acnt_prdt_cd,
"PDNO": pdno,
"ORD_QTY2": ord_qty2,
"BOND_ORD_UNPR": bond_ord_unpr,
"SAMT_MKET_PTCI_YN": samt_mket_ptci_yn,
"BOND_RTL_MKET_YN": bond_rtl_mket_yn,
"IDCR_STFNO": idcr_stfno,
"MGCO_APTM_ODNO": mgco_aptm_odno,
"ORD_SVR_DVSN_CD": ord_svr_dvsn_cd,
"CTAC_TLNO": ctac_tlno,
}
res = ka._url_fetch(api_url=API_URL,
ptr_id=tr_id,
tr_cont="",
params=params,
postFlag=True
)
if res.isOK():
if hasattr(res.getBody(), 'output'):
output_data = res.getBody().output
if not isinstance(output_data, list):
output_data = [output_data]
dataframe = pd.DataFrame(output_data)
else:
dataframe = pd.DataFrame()
logger.info("Data fetch complete.")
return dataframe
else:
logger.error("API call failed: %s - %s", res.getErrorCode(), res.getErrorMessage())
res.printError(API_URL)
return pd.DataFrame()

View File

@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-20
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from buy import buy
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 주문/계좌 > 장내채권 매수주문 [국내주식-124]
##############################################################################################
COLUMN_MAPPING = {
'KRX_FWDG_ORD_ORGNO': '한국거래소전송주문조직번호',
'ODNO': '주문번호',
'ORD_TMD': '주문시각'
}
NUMERIC_COLUMNS = []
def main():
"""
[장내채권] 주문/계좌`
장내채권 매수주문[국내주식-124]
장내채권 매수주문 테스트 함수
Parameters:
cano (str): 종합계좌번호 (8자리)
acnt_prdt_cd (str): 계좌상품코드 (2자리)
pdno (str): 상품번호 (12자리)
ord_qty2 (str): 주문수량2 (19자리)
bond_ord_unpr (str): 채권주문단가 (182자리)
samt_mket_ptci_yn (str): 소액시장참여여부 ('Y' or 'N')
bond_rtl_mket_yn (str): 채권소매시장여부 ('Y' or 'N')
idcr_stfno (str, optional): 유치자직원번호 (6자리). Defaults to "".
mgco_aptm_odno (str, optional): 운용사지정주문번호 (12자리). Defaults to "".
Returns:
- DataFrame: 장내채권 매수주문 결과
Example:
>>> df = main()
>>> print(df)
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 토큰 발급
logger.info("토큰 발급 중...")
ka.auth()
logger.info("토큰 발급 완료")
# kis_auth 모듈에서 계좌 정보 가져오기
trenv = ka.getTREnv()
# API 호출
logger.info("API 호출 시작: 장내채권 매수주문")
result = buy(
cano=trenv.my_acct, # 종합계좌번호
acnt_prdt_cd=trenv.my_prod, # 계좌상품코드
pdno="KR6095572D81", # 상품번호
ord_qty2="10", # 주문수량
bond_ord_unpr="9900", # 채권주문단가
samt_mket_ptci_yn="N", # 소액시장참여여부
bond_rtl_mket_yn="N", # 채권소매시장여부
idcr_stfno="", # 유치자직원번호
mgco_aptm_odno="", # 운용사지정주문번호
ord_svr_dvsn_cd="0", # 주문서버구분코드
ctac_tlno="", # 연락전화번호
)
if result is None or result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 컬럼명 출력
logger.info("사용 가능한 컬럼 목록:")
logger.info(result.columns.tolist())
# 한글 컬럼명으로 변환
result = result.rename(columns=COLUMN_MAPPING)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
# 결과 출력
logger.info("=== 장내채권 매수주문 결과 ===")
logger.info("조회된 데이터 건수: %d", len(result))
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import logging
import sys
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from inquire_asking_price import inquire_asking_price
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 기본시세 > 장내채권현재가(호가) [국내주식-132]
##############################################################################################
COLUMN_MAPPING = {
'aspr_acpt_hour': '호가 접수 시간',
'bond_askp1': '채권 매도호가1',
'bond_askp2': '채권 매도호가2',
'bond_askp3': '채권 매도호가3',
'bond_askp4': '채권 매도호가4',
'bond_askp5': '채권 매도호가5',
'bond_bidp1': '채권 매수호가1',
'bond_bidp2': '채권 매수호가2',
'bond_bidp3': '채권 매수호가3',
'bond_bidp4': '채권 매수호가4',
'bond_bidp5': '채권 매수호가5',
'askp_rsqn1': '매도호가 잔량1',
'askp_rsqn2': '매도호가 잔량2',
'askp_rsqn3': '매도호가 잔량3',
'askp_rsqn4': '매도호가 잔량4',
'askp_rsqn5': '매도호가 잔량5',
'bidp_rsqn1': '매수호가 잔량1',
'bidp_rsqn2': '매수호가 잔량2',
'bidp_rsqn3': '매수호가 잔량3',
'bidp_rsqn4': '매수호가 잔량4',
'bidp_rsqn5': '매수호가 잔량5',
'total_askp_rsqn': '총 매도호가 잔량',
'total_bidp_rsqn': '총 매수호가 잔량',
'ntby_aspr_rsqn': '순매수 호가 잔량',
'seln_ernn_rate1': '매도 수익 비율1',
'seln_ernn_rate2': '매도 수익 비율2',
'seln_ernn_rate3': '매도 수익 비율3',
'seln_ernn_rate4': '매도 수익 비율4',
'seln_ernn_rate5': '매도 수익 비율5',
'shnu_ernn_rate1': '매수2 수익 비율1',
'shnu_ernn_rate2': '매수2 수익 비율2',
'shnu_ernn_rate3': '매수2 수익 비율3',
'shnu_ernn_rate4': '매수2 수익 비율4',
'shnu_ernn_rate5': '매수2 수익 비율5'
}
NUMERIC_COLUMNS = []
def main():
"""
[장내채권] 기본시세
장내채권현재가(호가)[국내주식-132]
장내채권현재가(호가) 테스트 함수
Parameters:
- fid_cond_mrkt_div_code (str): 시장 분류 코드 (B 입력)
- fid_input_iscd (str): 채권종목코드
Returns:
- DataFrame: 장내채권현재가(호가) 결과
Example:
>>> df = inquire_asking_price(fid_cond_mrkt_div_code="B", fid_input_iscd="KR2033022D33")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 토큰 발급
logger.info("토큰 발급 중...")
ka.auth()
logger.info("토큰 발급 완료")
# API 호출
logger.info("API 호출 시작: 장내채권현재가(호가)")
result = inquire_asking_price(
fid_cond_mrkt_div_code="B", # 시장 분류 코드
fid_input_iscd="KR2033022D33", # 채권종목코드
)
if result is None or result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 컬럼명 출력
logger.info("사용 가능한 컬럼 목록:")
logger.info(result.columns.tolist())
# 한글 컬럼명으로 변환
result = result.rename(columns=COLUMN_MAPPING)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
# 결과 출력
logger.info("=== 장내채권현재가(호가) 결과 ===")
logger.info("조회된 데이터 건수: %d", len(result))
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,116 @@
# [장내채권] 기본시세 - 장내채권현재가(호가)
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import logging
import time
from typing import Optional, Tuple
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 기본시세 > 장내채권현재가(호가) [국내주식-132]
##############################################################################################
# 상수 정의
API_URL = "/uapi/domestic-bond/v1/quotations/inquire-asking-price"
def inquire_asking_price(
fid_cond_mrkt_div_code: str, # 시장 분류 코드
fid_input_iscd: str, # 채권종목코드
tr_cont: str = "", # 연속 거래 여부
dataframe: Optional[pd.DataFrame] = None, # 누적 데이터프레임
depth: int = 0, # 현재 재귀 깊이
max_depth: int = 10 # 최대 재귀 깊이
) -> Optional[pd.DataFrame]:
"""
[장내채권] 기본시세
장내채권현재가(호가)[국내주식-132]
장내채권현재가(호가) API를 호출하여 DataFrame으로 반환합니다.
Args:
fid_cond_mrkt_div_code (str): 시장 분류 코드 (B 입력)
fid_input_iscd (str): 채권종목코드 (ex KR2033022D33)
tr_cont (str): 연속 거래 여부 (기본값: "")
dataframe (Optional[pd.DataFrame]): 누적 데이터프레임 (기본값: None)
depth (int): 현재 재귀 깊이 (기본값: 0)
max_depth (int): 최대 재귀 깊이 (기본값: 10)
Returns:
Optional[pd.DataFrame]: 장내채권현재가(호가) 데이터
Example:
>>> df = inquire_asking_price(fid_cond_mrkt_div_code="B", fid_input_iscd="KR2033022D33")
>>> print(df)
"""
# 필수 파라미터 검증
if not fid_cond_mrkt_div_code:
logger.error("fid_cond_mrkt_div_code is required. (e.g. 'B')")
raise ValueError("fid_cond_mrkt_div_code is required. (e.g. 'B')")
if not fid_input_iscd:
logger.error("fid_input_iscd is required. (e.g. 'KR2033022D33')")
raise ValueError("fid_input_iscd is required. (e.g. 'KR2033022D33')")
# 최대 재귀 깊이 체크
if depth >= max_depth:
logger.warning("Maximum recursion depth (%d) reached. Stopping further requests.", max_depth)
return dataframe if dataframe is not None else pd.DataFrame()
tr_id = "FHKBJ773401C0"
params = {
"FID_COND_MRKT_DIV_CODE": fid_cond_mrkt_div_code,
"FID_INPUT_ISCD": fid_input_iscd,
}
# API 호출
res = ka._url_fetch(API_URL, tr_id, tr_cont, params)
if res.isOK():
# 응답 데이터 처리
if hasattr(res.getBody(), 'output'):
output_data = res.getBody().output
if not isinstance(output_data, list):
output_data = [output_data]
current_data = pd.DataFrame(output_data)
else:
current_data = pd.DataFrame()
# 데이터프레임 병합
if dataframe is not None:
dataframe = pd.concat([dataframe, current_data], ignore_index=True)
else:
dataframe = current_data
# 연속 거래 여부 확인
tr_cont = res.getHeader().tr_cont
if tr_cont == "M":
logger.info("Calling next page...")
ka.smart_sleep()
return inquire_asking_price(
fid_cond_mrkt_div_code,
fid_input_iscd,
"N", dataframe, depth + 1, max_depth
)
else:
logger.info("Data fetch complete.")
return dataframe
else:
# API 에러 처리
logger.error("API call failed: %s - %s", res.getErrorCode(), res.getErrorMessage())
res.printError(API_URL)
return pd.DataFrame()

View File

@@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-20
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from inquire_balance import inquire_balance
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 주문/계좌 > 장내채권 잔고조회 [국내주식-198]
##############################################################################################
COLUMN_MAPPING = {
'pdno': '상품번호',
'buy_dt': '매수일자',
'buy_sqno': '매수일련번호',
'cblc_qty': '잔고수량',
'agrx_qty': '종합과세수량',
'sprx_qty': '분리과세수량',
'exdt': '만기일',
'buy_erng_rt': '매수수익율',
'buy_unpr': '매수단가',
'buy_amt': '매수금액',
'ord_psbl_qty': '주문가능수량'
}
NUMERIC_COLUMNS = []
def main():
"""
[장내채권] 주문/계좌
장내채권 잔고조회[국내주식-198]
장내채권 잔고조회 테스트 함수
Parameters:
- cano (str): 종합계좌번호 ()
- acnt_prdt_cd (str): 계좌상품코드 ()
- inqr_cndt (str): 조회조건 (00: 전체, 01: 상품번호단위)
- pdno (str): 상품번호 (공백)
- buy_dt (str): 매수일자 (공백)
Returns:
- DataFrame: 장내채권 잔고조회 결과
Example:
>>> df = inquire_balance(cano=trenv.my_acct, acnt_prdt_cd=trenv.my_prod, inqr_cndt="00", pdno="", buy_dt="")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 토큰 발급
logger.info("토큰 발급 중...")
ka.auth()
logger.info("토큰 발급 완료")
# kis_auth 모듈에서 계좌 정보 가져오기
trenv = ka.getTREnv()
# API 호출
logger.info("API 호출 시작: 장내채권 잔고조회")
result = inquire_balance(
cano=trenv.my_acct, # 종합계좌번호
acnt_prdt_cd=trenv.my_prod, # 계좌상품코드
inqr_cndt="00", # 조회조건
pdno="", # 상품번호
buy_dt="", # 매수일자
)
if result is None or result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 컬럼명 출력
logger.info("사용 가능한 컬럼 목록:")
logger.info(result.columns.tolist())
# 한글 컬럼명으로 변환
result = result.rename(columns=COLUMN_MAPPING)
# 숫자형 컬럼 변환
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
# 결과 출력
logger.info("=== 장내채권 잔고조회 결과 ===")
logger.info("조회된 데이터 건수: %d", len(result))
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,147 @@
# [장내채권] 주문/계좌 - 장내채권 잔고조회
# Generated by KIS API Generator (Single API Mode)
# -*- coding: utf-8 -*-
"""
Created on 2025-06-20
"""
import logging
import time
from typing import Optional
import sys
import pandas as pd
sys.path.extend(['../..', '.'])
import kis_auth as ka
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 주문/계좌 > 장내채권 잔고조회 [국내주식-198]
##############################################################################################
# 상수 정의
API_URL = "/uapi/domestic-bond/v1/trading/inquire-balance"
def inquire_balance(
cano: str, # 종합계좌번호
acnt_prdt_cd: str, # 계좌상품코드
inqr_cndt: str, # 조회조건
pdno: str, # 상품번호
buy_dt: str, # 매수일자
FK200: str = "", # 연속조회검색조건200
NK200: str = "", # 연속조회키200
tr_cont: str = "", # 연속 거래 여부
dataframe: Optional[pd.DataFrame] = None, # 누적 데이터프레임
depth: int = 0, # 현재 재귀 깊이
max_depth: int = 10 # 최대 재귀 깊이
) -> Optional[pd.DataFrame]:
"""
[장내채권] 주문/계좌
장내채권 잔고조회[국내주식-198]
장내채권 잔고조회 API를 호출하여 DataFrame으로 반환합니다.
Args:
cano (str): 종합계좌번호
acnt_prdt_cd (str): 계좌상품코드
inqr_cndt (str): 조회조건 (00: 전체, 01: 상품번호단위)
pdno (str): 상품번호 (공백 허용)
buy_dt (str): 매수일자 (공백 허용)
FK200 (str): 연속조회검색조건200
NK200 (str): 연속조회키200
tr_cont (str): 연속 거래 여부 (기본값: "")
dataframe (Optional[pd.DataFrame]): 누적 데이터프레임
depth (int): 현재 재귀 깊이
max_depth (int): 최대 재귀 깊이 (기본값: 10)
Returns:
Optional[pd.DataFrame]: 장내채권 잔고조회 데이터
Example:
>>> df = inquire_balance(
... cano=trenv.my_acct,
... acnt_prdt_cd=trenv.my_prod,
... inqr_cndt='00',
... pdno='',
... buy_dt='',
... )
>>> print(df)
"""
# 로깅 설정
logger = logging.getLogger(__name__)
# 필수 파라미터 검증
if not cano:
logger.error("cano is required. (e.g. '12345678')")
raise ValueError("cano is required. (e.g. '12345678')")
if not acnt_prdt_cd:
logger.error("acnt_prdt_cd is required. (e.g. '01')")
raise ValueError("acnt_prdt_cd is required. (e.g. '01')")
if not inqr_cndt:
logger.error("inqr_cndt is required. (e.g. '00')")
raise ValueError("inqr_cndt is required. (e.g. '00')")
# 최대 재귀 깊이 체크
if depth >= max_depth:
logger.warning("Maximum recursion depth (%d) reached. Stopping further requests.", max_depth)
return dataframe if dataframe is not None else pd.DataFrame()
tr_id = "CTSC8407R"
params = {
"CANO": cano,
"ACNT_PRDT_CD": acnt_prdt_cd,
"INQR_CNDT": inqr_cndt,
"PDNO": pdno,
"BUY_DT": buy_dt,
"CTX_AREA_FK200": FK200,
"CTX_AREA_NK200": NK200,
}
# API 호출
res = ka._url_fetch(API_URL, tr_id, tr_cont, params)
if res.isOK():
if hasattr(res.getBody(), 'output'):
output_data = res.getBody().output
if not isinstance(output_data, list):
output_data = [output_data]
current_data = pd.DataFrame(output_data)
else:
current_data = pd.DataFrame()
if dataframe is not None:
dataframe = pd.concat([dataframe, current_data], ignore_index=True)
else:
dataframe = current_data
tr_cont = res.getHeader().tr_cont
NK200 = res.getBody().ctx_area_nk200
FK200 = res.getBody().ctx_area_fk200
if tr_cont == "M":
logger.info("Calling next page...")
ka.smart_sleep()
return inquire_balance(
cano,
acnt_prdt_cd,
inqr_cndt,
pdno,
buy_dt,
FK200,
NK200,
"N", dataframe, depth + 1, max_depth
)
else:
logger.info("Data fetch complete.")
return dataframe
else:
logger.error("API call failed: %s - %s", res.getErrorCode(), res.getErrorMessage())
res.printError(API_URL)
return pd.DataFrame()

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
Created on 2025-06-19
"""
import sys
import logging
import pandas as pd
sys.path.extend(['../..', '.']) # kis_auth 파일 경로 추가
import kis_auth as ka
from inquire_ccnl import inquire_ccnl
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
##############################################################################################
# [장내채권] 기본시세 > 장내채권현재가(체결) [국내주식-201]
##############################################################################################
COLUMN_MAPPING = {
'output1': '응답상세',
'stck_cntg_hour': '주식 체결 시간',
'bond_prpr': '채권 현재가',
'bond_prdy_vrss': '채권 전일 대비',
'prdy_vrss_sign': '전일 대비 부호',
'prdy_ctrt': '전일 대비율',
'cntg_vol': '체결 거래량',
'acml_vol': '누적 거래량'
}
NUMERIC_COLUMNS = []
def main():
"""
[장내채권] 기본시세
장내채권현재가(체결)[국내주식-201]
장내채권현재가(체결) 테스트 함수
Parameters:
- fid_cond_mrkt_div_code (str): 조건시장분류코드 (B (업종코드))
- fid_input_iscd (str): 입력종목코드 (채권종목코드(ex KR2033022D33))
Returns:
- DataFrame: 장내채권현재가(체결) 결과
Example:
>>> df = inquire_ccnl(fid_cond_mrkt_div_code="B", fid_input_iscd="KR2033022D33")
"""
try:
# pandas 출력 옵션 설정
pd.set_option('display.max_columns', None) # 모든 컬럼 표시
pd.set_option('display.width', None) # 출력 너비 제한 해제
pd.set_option('display.max_rows', None) # 모든 행 표시
# 토큰 발급
logger.info("토큰 발급 중...")
ka.auth()
logger.info("토큰 발급 완료")
# API 호출
logger.info("API 호출 시작: 장내채권현재가(체결)")
result = inquire_ccnl(
fid_cond_mrkt_div_code="B", # 조건시장분류코드
fid_input_iscd="KR103502GA34", # 입력종목코드
)
if result is None or result.empty:
logger.warning("조회된 데이터가 없습니다.")
return
# 컬럼명 출력
logger.info("사용 가능한 컬럼 목록:")
logger.info(result.columns.tolist())
# 한글 컬럼명으로 변환
result = result.rename(columns=COLUMN_MAPPING)
# 숫자형 컬럼 변환s
for col in NUMERIC_COLUMNS:
if col in result.columns:
result[col] = pd.to_numeric(result[col], errors='coerce')
# 결과 출력
logger.info("=== 장내채권현재가(체결) 결과 ===")
logger.info("조회된 데이터 건수: %d", len(result))
print(result)
except Exception as e:
logger.error("에러 발생: %s", str(e))
raise
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More