Compare commits

...
4 Commits
Author SHA1 Message Date
romanesk a190a40bde 일단 커밋 2026-09-09 00:47:34 +09:00
romanesk 3008f8e0bf 보안 관련 권장사항 커밋 2026-09-09 00:47:11 +09:00
romanesk f1ad744a14 gitignore 업데이트와 참고용 종목마스터정보 헤더파일 추가 2026-07-17 15:27:33 +09:00
romanesk 182c2b38e6 feat: config.yaml 기반 설정 마이그레이션 및 대시보드/종목조회 기능 추가
- config.yaml에서 직접 KIS 설정 관리 (BaseSettings 제거)
- 대시보드 KIS 계좌 잔고 실시간 연동, 로컬 DB 폴백
- 종목 조회 페이지: 시세/호가/일봉차트 (canvas) 구현
- 호가 REST API 엔드포인트 (/api/stocks/{code}/orderbook) 추가
- 토큰 캐시(.auth_cache.json) 저장/복원, 1분 재시도 제한
- KIS WebSocket 자동 연결 + 재연결 로직
- httpx 타임아웃 10초→30초, 라우터 타임아웃 핸들링 (504)
- rate limit: requests_per_second 2.0 (모의투자 초당 2건)
- 사이드바 3메뉴: 실거래 / 과거기록 / 종목조회
2026-07-17 15:25:34 +09:00
49 changed files with 744 additions and 132 deletions
-16
View File
@@ -1,16 +0,0 @@
# 한국투자증권 Open API 설정
KIS_APP_KEY=your_app_key_here
KIS_APP_SECRET=your_app_secret_here
KIS_ACCOUNT_NO=12345678
KIS_ACCOUNT_CODE=01
KIS_HTS_ID=your_hts_id
# 서버 모드 (real: 실전투자, vps: 모의투자)
KIS_SERVER_MODE=vps
# 앱 설정
APP_HOST=0.0.0.0
APP_PORT=8000
APP_DEBUG=true
DB_PATH=./data/stock.db
LOG_LEVEL=INFO
+13 -3
View File
@@ -1,4 +1,14 @@
./.venv app/__pycache__/
./app/__pycache__/ app/core/__pycache__/
app/engine/__pycache__/
app/engine/strategies/__pycache__/
app/models/__pycache__/
app/routers/__pycache__/
app/services/__pycache__/
__pycache__/
.venv/
.auth_cache.json
.env
*.pyc *.pyc
output.log
config.yaml
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+79 -20
View File
@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib import json
import hmac
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
import httpx import httpx
@@ -17,6 +16,7 @@ logger = setup_logger("auth")
_TOKEN_URL = "/oauth2/tokenP" _TOKEN_URL = "/oauth2/tokenP"
_APPROVAL_URL = "/oauth2/Approval" _APPROVAL_URL = "/oauth2/Approval"
_HASHKEY_URL = "/uapi/hashkey" _HASHKEY_URL = "/uapi/hashkey"
_TOKEN_CACHE_PATH = Path(settings.app.db_path).parent / ".auth_cache.json"
class TokenManager: class TokenManager:
@@ -27,8 +27,41 @@ class TokenManager:
self._approval_expires_at: datetime = datetime.min self._approval_expires_at: datetime = datetime.min
self._rate_limiter = RateLimiter(requests_per_second=1.0) self._rate_limiter = RateLimiter(requests_per_second=1.0)
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._client = httpx.AsyncClient(timeout=10.0) self._client = httpx.AsyncClient(timeout=15.0)
self._authenticated: bool = False self._authenticated: bool = False
self._load_cache()
def _load_cache(self) -> None:
if not _TOKEN_CACHE_PATH.exists():
return
try:
data = json.loads(_TOKEN_CACHE_PATH.read_text())
now = datetime.now()
token_exp = datetime.fromisoformat(data.get("token_expires_at", ""))
if data.get("access_token") and now < token_exp:
self._access_token = data["access_token"]
self._token_expires_at = token_exp
self._authenticated = True
logger.info("캐시에서 토큰 복원 완료 (만료: %s)", token_exp)
approval_exp = datetime.fromisoformat(data.get("approval_expires_at", ""))
if data.get("approval_key") and now < approval_exp:
self._approval_key = data["approval_key"]
self._approval_expires_at = approval_exp
logger.info("캐시에서 approval_key 복원 완료 (만료: %s)", approval_exp)
except Exception as e:
logger.debug("토큰 캐시 로드 실패: %s", e)
def _save_cache(self) -> None:
try:
_TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_TOKEN_CACHE_PATH.write_text(json.dumps({
"access_token": self._access_token,
"token_expires_at": self._token_expires_at.isoformat(),
"approval_key": self._approval_key,
"approval_expires_at": self._approval_expires_at.isoformat(),
}))
except Exception as e:
logger.debug("토큰 캐시 저장 실패: %s", e)
@property @property
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
@@ -49,18 +82,30 @@ class TokenManager:
"appkey": settings.kis.app_key, "appkey": settings.kis.app_key,
"appsecret": settings.kis.app_secret, "appsecret": settings.kis.app_secret,
} }
resp = await self._client.post(url, json=payload)
data = resp.json()
if resp.status_code != 200 or "access_token" not in data: for attempt in range(3):
logger.warning("토큰 발급 실패: %s", data.get("message", resp.status_code)) resp = await self._client.post(url, json=payload)
data = resp.json()
if data.get("error_code") == "EGW00133":
wait = 65 * (attempt + 1)
logger.warning("토큰 발급 1분 제한 - %d초 후 재시도 (%d/3)", wait, attempt + 1)
await asyncio.sleep(wait)
continue
if resp.status_code != 200 or "access_token" not in data:
logger.warning("토큰 발급 실패: %s", data.get("message", resp.status_code))
return
self._access_token = data["access_token"]
expires_in = int(data.get("expires_in", 7776000))
self._token_expires_at = datetime.now() + timedelta(seconds=expires_in)
self._authenticated = True
self._save_cache()
logger.info("REST access_token 발급 완료 (만료: %s)", self._token_expires_at)
return return
self._access_token = data["access_token"] logger.error("토큰 발급 3회 모두 실패 (1분 제한)")
expires_in = int(data.get("expires_in", 7776000))
self._token_expires_at = datetime.now() + timedelta(seconds=expires_in)
self._authenticated = True
logger.info("REST access_token 발급 완료 (만료: %s)", self._token_expires_at)
async def _request_approval_key(self) -> None: async def _request_approval_key(self) -> None:
if not settings.kis.app_key or not settings.kis.app_secret: if not settings.kis.app_key or not settings.kis.app_secret:
@@ -72,16 +117,30 @@ class TokenManager:
"appkey": settings.kis.app_key, "appkey": settings.kis.app_key,
"secretkey": settings.kis.app_secret, "secretkey": settings.kis.app_secret,
} }
resp = await self._client.post(url, json=payload)
data = resp.json()
if resp.status_code != 200 or "approval_key" not in data: for attempt in range(3):
logger.warning("approval_key 발급 실패: %s", data.get("message", resp.status_code)) resp = await self._client.post(url, json=payload)
data = resp.json()
if data.get("error_code") == "EGW00133":
wait = 65 * (attempt + 1)
logger.warning(
"approval_key 발급 1분 제한 - %d초 후 재시도 (%d/3)", wait, attempt + 1
)
await asyncio.sleep(wait)
continue
if resp.status_code != 200 or "approval_key" not in data:
logger.warning("approval_key 발급 실패: %s", data.get("message", resp.status_code))
return
self._approval_key = data["approval_key"]
self._approval_expires_at = datetime.now() + timedelta(hours=23, minutes=50)
self._save_cache()
logger.info("WebSocket approval_key 발급 완료 (만료: %s)", self._approval_expires_at)
return return
self._approval_key = data["approval_key"] logger.error("approval_key 발급 3회 모두 실패 (1분 제한)")
self._approval_expires_at = datetime.now() + timedelta(hours=23, minutes=50)
logger.info("WebSocket approval_key 발급 완료 (만료: %s)", self._approval_expires_at)
async def get_access_token(self) -> str: async def get_access_token(self) -> str:
async with self._lock: async with self._lock:
+69 -20
View File
@@ -5,29 +5,68 @@ from pathlib import Path
from typing import Any from typing import Any
import yaml import yaml
from pydantic import Field
from pydantic_settings import BaseSettings
class KISConfig(BaseSettings): def _load_dotenv(path: Path) -> None:
app_key: str = Field(default="", alias="KIS_APP_KEY") """간단한 .env 파서 (python-dotenv 의존성 대신 내장 구현)."""
app_secret: str = Field(default="", alias="KIS_APP_SECRET") if not path.exists():
account_no: str = Field(default="", alias="KIS_ACCOUNT_NO") return
account_code: str = Field(default="01", alias="KIS_ACCOUNT_CODE") for line in path.read_text(encoding="utf-8").splitlines():
hts_id: str = Field(default="", alias="KIS_HTS_ID") line = line.strip()
server_mode: str = Field(default="vps", alias="KIS_SERVER_MODE") if not line or line.startswith("#") or "=" not in line:
continue
model_config = {"env_file": ".env", "extra": "ignore"} key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
class AppConfig(BaseSettings): _load_dotenv(Path(".env"))
host: str = Field(default="0.0.0.0", alias="APP_HOST")
port: int = Field(default=8000, alias="APP_PORT")
debug: bool = Field(default=False, alias="APP_DEBUG")
db_path: str = Field(default="./data/stock.db", alias="DB_PATH")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
model_config = {"env_file": ".env", "extra": "ignore"}
def _env(name: str, default: Any = None) -> Any:
return os.environ.get(name, default)
class KISConfig:
app_key: str = ""
app_secret: str = ""
account_no: str = ""
account_code: str = "01"
hts_id: str = ""
server_mode: str = "vps"
def apply_env(self) -> None:
app_key = _env("KIS_APP_KEY")
app_secret = _env("KIS_APP_SECRET")
account_no = _env("KIS_ACCOUNT_NO")
account_code = _env("KIS_ACCOUNT_CODE")
hts_id = _env("KIS_HTS_ID")
server_mode = _env("KIS_SERVER_MODE")
if app_key:
self.app_key = app_key
if app_secret:
self.app_secret = app_secret
if account_no:
self.account_no = account_no
if account_code:
self.account_code = account_code
if hts_id:
self.hts_id = hts_id
if server_mode:
self.server_mode = server_mode
class AppConfig:
name: str = "StockAutomation"
version: str = "0.1.0"
host: str = "0.0.0.0"
port: int = 8000
debug: bool = False
db_path: str = "./data/stock.db"
log_level: str = "INFO"
class CollectorConfig: class CollectorConfig:
@@ -63,6 +102,7 @@ class Settings:
self.trading = TradingConfig() self.trading = TradingConfig()
self.rate_limit = RateLimitConfig() self.rate_limit = RateLimitConfig()
self._load_yaml() self._load_yaml()
self.kis.apply_env()
def _load_yaml(self) -> None: def _load_yaml(self) -> None:
yaml_path = Path("config.yaml") yaml_path = Path("config.yaml")
@@ -72,9 +112,18 @@ class Settings:
with open(yaml_path, encoding="utf-8") as f: with open(yaml_path, encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {} data: dict[str, Any] = yaml.safe_load(f) or {}
app = data.get("app", {})
for key, val in app.items():
if hasattr(self.app, key):
setattr(self.app, key, val)
kis = data.get("kis", {}) kis = data.get("kis", {})
if "server_mode" in kis: for key, val in kis.items():
self.kis.server_mode = kis["server_mode"] if key == "rate_limit":
continue
if hasattr(self.kis, key):
setattr(self.kis, key, val)
rl = kis.get("rate_limit", {}) rl = kis.get("rate_limit", {})
if "requests_per_second" in rl: if "requests_per_second" in rl:
self.rate_limit.requests_per_second = rl["requests_per_second"] self.rate_limit.requests_per_second = rl["requests_per_second"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25 -12
View File
@@ -4,15 +4,17 @@ import json
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.auth import token_manager
from app.core.config import settings from app.core.config import settings
from app.core.logger import setup_logger from app.core.logger import setup_logger
from app.models.stock import PriceHistory, Strategy, Trade
from app.services.market_data import market_data_service
from app.services.trading import trading_service
from app.engine.strategies.base import BaseStrategy, Signal from app.engine.strategies.base import BaseStrategy, Signal
from app.engine.strategies.conditional import ConditionalStrategy from app.engine.strategies.conditional import ConditionalStrategy
from app.engine.strategies.technical import TechnicalStrategy
from app.engine.strategies.periodic import PeriodicStrategy from app.engine.strategies.periodic import PeriodicStrategy
from app.engine.strategies.technical import TechnicalStrategy
from app.models.stock import PriceHistory, Strategy, Trade
from app.services.account import account_service
from app.services.market_data import market_data_service
from app.services.trading import trading_service
logger = setup_logger("strategy_engine") logger = setup_logger("strategy_engine")
@@ -40,9 +42,25 @@ class StrategyEngine:
strategy_id=strategy_record.id, strategy_id=strategy_record.id,
) )
async def _get_real_holdings(self, db: Session) -> dict[str, int]:
"""실제 계좌 잔고 기반 보유수량 맵. 실패 시 로컬 DB로 폴백."""
if token_manager.is_authenticated:
try:
balance = await account_service.get_balance()
holdings = {s["stock_code"]: s["qty"] for s in balance.get("stocks", [])}
if holdings:
return holdings
except Exception as e:
logger.warning("잔고 조회 실패 - 로컬 보유정보 사용: %s", e)
from app.models.stock import Holding
return {h.stock_code: h.qty for h in db.query(Holding).filter(Holding.qty > 0).all()}
async def evaluate_all(self, db: Session) -> list[Signal]: async def evaluate_all(self, db: Session) -> list[Signal]:
strategies = db.query(Strategy).filter(Strategy.is_active == True).all() strategies = db.query(Strategy).filter(Strategy.is_active).all()
signals: list[Signal] = [] signals: list[Signal] = []
real_holdings = await self._get_real_holdings(db)
for strat_record in strategies: for strat_record in strategies:
try: try:
@@ -55,7 +73,7 @@ class StrategyEngine:
continue continue
price_history = self._get_price_history(db, strat_record.stock_code) price_history = self._get_price_history(db, strat_record.stock_code)
holding = self._get_holding_qty(db, strat_record.stock_code) holding = real_holdings.get(strat_record.stock_code, 0)
signal = strategy.evaluate(current_price, price_history, holding) signal = strategy.evaluate(current_price, price_history, holding)
if signal.action != "hold": if signal.action != "hold":
@@ -103,7 +121,7 @@ class StrategyEngine:
qty=signal.qty, qty=signal.qty,
price=signal.price, price=signal.price,
order_type=settings.strategy.default_order_type, order_type=settings.strategy.default_order_type,
status="filled" if order_result.get("rt_cd") == "0" else "rejected", status="pending" if order_result.get("rt_cd") == "0" else "rejected",
strategy_id=signal.strategy_id, strategy_id=signal.strategy_id,
) )
db.add(trade) db.add(trade)
@@ -136,11 +154,6 @@ class StrategyEngine:
for r in reversed(records) for r in reversed(records)
] ]
def _get_holding_qty(self, db: Session, stock_code: str) -> int:
from app.models.stock import Holding
holding = db.query(Holding).filter(Holding.stock_code == stock_code).first()
return holding.qty if holding else 0
def reset_daily_count(self) -> None: def reset_daily_count(self) -> None:
self._daily_trade_count = 0 self._daily_trade_count = 0
+5
View File
@@ -44,6 +44,11 @@ async def lifespan(app: FastAPI):
if token_manager.is_authenticated: if token_manager.is_authenticated:
start_scheduler() start_scheduler()
try:
await realtime_service.connect()
logger.info("KIS WebSocket 연결 완료")
except Exception as e:
logger.warning("KIS WebSocket 연결 실패: %s", e)
else: else:
logger.info("인증 실패 - 스케줄러 미시작 (인증 후 수동 시작 가능)") logger.info("인증 실패 - 스케줄러 미시작 (인증 후 수동 시작 가능)")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+52 -18
View File
@@ -5,22 +5,59 @@ from sqlalchemy.orm import Session
from app.core.auth import token_manager from app.core.auth import token_manager
from app.core.database import get_db from app.core.database import get_db
from app.models.stock import Holding, PriceHistory, Strategy, Trade, Stock from app.models.stock import Holding, Strategy, Trade, Stock
from app.engine.scheduler import scheduler from app.engine.scheduler import scheduler
from app.services.account import account_service
from app.services.realtime import realtime_service
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/") @router.get("/")
def dashboard(db: Session = Depends(get_db)) -> dict: async def dashboard(db: Session = Depends(get_db)) -> dict:
holdings = db.query(Holding).all()
recent_trades = db.query(Trade).order_by(Trade.created_at.desc()).limit(20).all() recent_trades = db.query(Trade).order_by(Trade.created_at.desc()).limit(20).all()
active_strategies = db.query(Strategy).filter(Strategy.is_active == True).all() active_strategies = db.query(Strategy).filter(Strategy.is_active == True).all()
active_stocks = db.query(Stock).filter(Stock.is_active == True).all() active_stocks = db.query(Stock).filter(Stock.is_active == True).all()
total_profit = sum(h.profit for h in holdings) holdings = []
total_invested = sum(h.avg_price * h.qty for h in holdings if h.avg_price > 0) total_profit = 0.0
total_evaluated = sum(h.current_price * h.qty for h in holdings if h.current_price > 0) total_invested = 0.0
total_evaluated = 0.0
if token_manager.is_authenticated:
try:
balance = await account_service.get_balance()
for s in balance.get("stocks", []):
holdings.append({
"stock_code": s["stock_code"],
"stock_name": s["stock_name"],
"qty": s["qty"],
"avg_price": s["avg_price"],
"current_price": s["current_price"],
"profit": s["profit"],
"profit_rate": s["profit_rate"],
})
total_invested += s.get("buy_amount", s["avg_price"] * s["qty"])
total_evaluated += s.get("eval_amount", s["current_price"] * s["qty"])
total_profit += s["profit"]
except Exception:
pass
if not holdings:
local = db.query(Holding).all()
for h in local:
holdings.append({
"stock_code": h.stock_code,
"stock_name": h.stock_name,
"qty": h.qty,
"avg_price": h.avg_price,
"current_price": h.current_price,
"profit": h.profit,
"profit_rate": h.profit_rate,
})
total_invested += h.avg_price * h.qty
total_evaluated += h.current_price * h.qty
total_profit += h.profit
jobs = [] jobs = []
if scheduler.running: if scheduler.running:
@@ -42,18 +79,7 @@ def dashboard(db: Session = Depends(get_db)) -> dict:
"active_strategies": len(active_strategies), "active_strategies": len(active_strategies),
"active_stocks": len(active_stocks), "active_stocks": len(active_stocks),
}, },
"holdings": [ "holdings": holdings,
{
"stock_code": h.stock_code,
"stock_name": h.stock_name,
"qty": h.qty,
"avg_price": h.avg_price,
"current_price": h.current_price,
"profit": h.profit,
"profit_rate": h.profit_rate,
}
for h in holdings
],
"recent_trades": [ "recent_trades": [
{ {
"id": t.id, "id": t.id,
@@ -68,3 +94,11 @@ def dashboard(db: Session = Depends(get_db)) -> dict:
], ],
"scheduler_jobs": jobs, "scheduler_jobs": jobs,
} }
@router.get("/status")
def get_status() -> dict:
return {
"authenticated": token_manager.is_authenticated,
"realtime_connected": realtime_service.is_connected,
}
+20 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -63,7 +64,10 @@ def remove_stock(stock_code: str, db: Session = Depends(get_db)) -> dict:
@router.get("/{stock_code}/price") @router.get("/{stock_code}/price")
async def get_price(stock_code: str) -> dict: async def get_price(stock_code: str) -> dict:
price = await market_data_service.get_current_price(stock_code) try:
price = await market_data_service.get_current_price(stock_code)
except (httpx.TimeoutException, httpx.ConnectError):
raise HTTPException(status_code=504, detail="KIS 서버 응답 시간 초과")
if not price: if not price:
raise HTTPException(status_code=404, detail="시세 조회 실패") raise HTTPException(status_code=404, detail="시세 조회 실패")
return price return price
@@ -75,5 +79,19 @@ async def get_chart(stock_code: str, days: int = 30) -> list[dict]:
end = datetime.now().strftime("%Y%m%d") end = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d") start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d")
chart = await market_data_service.get_daily_chart(stock_code, start, end, days) try:
chart = await market_data_service.get_daily_chart(stock_code, start, end, days)
except (httpx.TimeoutException, httpx.ConnectError):
raise HTTPException(status_code=504, detail="KIS 서버 응답 시간 초과")
return chart return chart
@router.get("/{stock_code}/orderbook")
async def get_orderbook(stock_code: str) -> dict:
try:
orderbook = await market_data_service.get_orderbook(stock_code)
except (httpx.TimeoutException, httpx.ConnectError):
raise HTTPException(status_code=504, detail="KIS 서버 응답 시간 초과")
if not orderbook:
raise HTTPException(status_code=404, detail="호가 조회 실패")
return orderbook
+1 -1
View File
@@ -46,7 +46,7 @@ async def place_order(data: OrderRequest, db: Session = Depends(get_db)) -> dict
qty=data.qty, qty=data.qty,
price=data.price, price=data.price,
order_type=data.order_type, order_type=data.order_type,
status="filled" if result.get("rt_cd") == "0" else "rejected", status="pending" if result.get("rt_cd") == "0" else "rejected",
) )
db.add(trade) db.add(trade)
db.commit() db.commit()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -4
View File
@@ -19,7 +19,7 @@ _FID_COND_MRKT_DIV_CODE = "FID_COND_MRKT_DIV_CODE"
class MarketDataService: class MarketDataService:
def __init__(self) -> None: def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=10.0) self._client = httpx.AsyncClient(timeout=30.0)
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second) self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
def _check_auth(self) -> bool: def _check_auth(self) -> bool:
@@ -38,7 +38,9 @@ class MarketDataService:
url = f"{get_base_url()}{_PRICE_URL}" url = f"{get_base_url()}{_PRICE_URL}"
resp = await self._client.get(url, headers=headers, params=params) resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status() if resp.status_code != 200:
logger.warning("현재가 조회 HTTP 오류: %s - %s", stock_code, resp.status_code)
return {}
data = resp.json() data = resp.json()
if data.get("rt_cd") != "0": if data.get("rt_cd") != "0":
@@ -78,7 +80,9 @@ class MarketDataService:
url = f"{get_base_url()}{_CHART_URL}" url = f"{get_base_url()}{_CHART_URL}"
resp = await self._client.get(url, headers=headers, params=params) resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status() if resp.status_code != 200:
logger.warning("차트 조회 HTTP 오류: %s - %s", stock_code, resp.status_code)
return []
data = resp.json() data = resp.json()
if data.get("rt_cd") != "0": if data.get("rt_cd") != "0":
@@ -107,7 +111,9 @@ class MarketDataService:
url = f"{get_base_url()}{_ORDERBOOK_URL}" url = f"{get_base_url()}{_ORDERBOOK_URL}"
resp = await self._client.get(url, headers=headers, params=params) resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status() if resp.status_code != 200:
logger.warning("호가 조회 HTTP 오류: %s - %s", stock_code, resp.status_code)
return {}
data = resp.json() data = resp.json()
if data.get("rt_cd") != "0": if data.get("rt_cd") != "0":
+11 -4
View File
@@ -30,6 +30,10 @@ class RealtimeService:
self._heartbeat_task: asyncio.Task | None = None self._heartbeat_task: asyncio.Task | None = None
self._receive_task: asyncio.Task | None = None self._receive_task: asyncio.Task | None = None
@property
def is_connected(self) -> bool:
return self._ws is not None and self._running
def on(self, event: str, callback: Callable) -> None: def on(self, event: str, callback: Callable) -> None:
self._callbacks.setdefault(event, []).append(callback) self._callbacks.setdefault(event, []).append(callback)
@@ -40,7 +44,7 @@ class RealtimeService:
try: try:
self._ws = await websockets.connect( self._ws = await websockets.connect(
url, url,
extra_headers={"approval_key": approval_key, "type": "Y"}, additional_headers={"approval_key": approval_key, "type": "Y"},
) )
self._running = True self._running = True
self._receive_task = asyncio.create_task(self._receive_loop()) self._receive_task = asyncio.create_task(self._receive_loop())
@@ -106,11 +110,14 @@ class RealtimeService:
await self._handle_message(raw_msg) await self._handle_message(raw_msg)
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
logger.warning("WebSocket 연결 끊김. 5초 후 재연결...") logger.warning("WebSocket 연결 끊김. 5초 후 재연결...")
if self._running:
await asyncio.sleep(5)
await self.connect()
except Exception as e: except Exception as e:
logger.error("WebSocket 수신 오류: %s", e) logger.error("WebSocket 수신 오류: %s", e)
if self._running:
await asyncio.sleep(5)
try:
await self.connect()
except Exception:
logger.error("WebSocket 재연결 실패")
async def _handle_message(self, raw_msg: str) -> None: async def _handle_message(self, raw_msg: str) -> None:
try: try:
+48 -16
View File
@@ -57,6 +57,36 @@ class TradingService:
if not self._check_auth(): if not self._check_auth():
return {"rt_cd": "-1", "msg": "API 인증되지 않음", "order_no": ""} return {"rt_cd": "-1", "msg": "API 인증되지 않음", "order_no": ""}
if qty <= 0:
return {"rt_cd": "-1", "msg": "주문 수량은 0보다 커야 합니다", "order_no": ""}
if side not in ("buy", "sell"):
return {"rt_cd": "-1", "msg": "유효하지 않은 주문 방향입니다", "order_no": ""}
if order_type == "00" and price <= 0:
return {"rt_cd": "-1", "msg": "지정가 주문의 가격은 0보다 커야 합니다", "order_no": ""}
order_amount = qty * price
if price > 0:
if order_amount < settings.trading.min_order_amount:
return {
"rt_cd": "-1",
"msg": (
f"최소 주문 금액 미달: {order_amount:,}원 "
f"< {settings.trading.min_order_amount:,}원"
),
"order_no": "",
}
if order_amount > settings.trading.max_order_amount:
return {
"rt_cd": "-1",
"msg": (
f"최대 주문 금액 초과: {order_amount:,}원 "
f"> {settings.trading.max_order_amount:,}원"
),
"order_no": "",
}
await self._rate_limiter.acquire() await self._rate_limiter.acquire()
tr_id = self._get_tr_id(side) tr_id = self._get_tr_id(side)
@@ -237,22 +267,24 @@ class TradingService:
else: else:
side = "buy" if side_code == "01" else "sell" side = "buy" if side_code == "01" else "sell"
trades.append({ trades.append(
"order_no": item.get("ord_gno_brno", ""), {
"stock_code": item.get("pdno", ""), "order_no": item.get("ord_gno_brno", ""),
"stock_name": item.get("prdt_name", ""), "stock_code": item.get("pdno", ""),
"side": side, "stock_name": item.get("prdt_name", ""),
"qty": qty, "side": side,
"price": int(float(item.get("ft_ccld_unpr3", 0))), "qty": qty,
"order_type": item.get("ord_dvsn", "00"), "price": int(float(item.get("ft_ccld_unpr3", 0))),
"status": "filled", "order_type": item.get("ord_dvsn", "00"),
"order_amount": float(item.get("ft_ord_amt", 0)), "status": "filled",
"settlement_amount": float(item.get("ft_ccld_amt", 0)), "order_amount": float(item.get("ft_ord_amt", 0)),
"tax": float(item.get("sttl_evlu_amt", 0)), "settlement_amount": float(item.get("ft_ccld_amt", 0)),
"commission": float(item.get("ft_lof_ruse_amt", 0)), "tax": float(item.get("sttl_evlu_amt", 0)),
"created_at": item.get("ord_sttm", ""), "commission": float(item.get("ft_lof_ruse_amt", 0)),
"filled_at": item.get("ft_ccld_no", ""), "created_at": item.get("ord_sttm", ""),
}) "filled_at": item.get("ft_ccld_no", ""),
}
)
logger.info("일일 체결 내역 조회: %d건", len(trades)) logger.info("일일 체결 내역 조회: %d건", len(trades))
return trades return trades
+209 -14
View File
@@ -60,6 +60,19 @@
.filter-row { display: flex; gap: 12px; align-items: end; margin-bottom: 16px; flex-wrap: wrap; } .filter-row { display: flex; gap: 12px; align-items: end; margin-bottom: 16px; flex-wrap: wrap; }
.positive { color: #3fb950; } .positive { color: #3fb950; }
.negative { color: #f85149; } .negative { color: #f85149; }
.stock-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.stock-header .name { font-size: 22px; font-weight: 700; color: #e1e4e8; }
.stock-header .code { font-size: 14px; color: #8b949e; }
.stock-price-main { font-size: 32px; font-weight: 700; margin-bottom: 4px; }
.stock-change { font-size: 15px; margin-bottom: 20px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.orderbook-table { width: 100%; }
.orderbook-table td { text-align: right; padding: 5px 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
.orderbook-table td:first-child { text-align: center; color: #8b949e; width: 40px; }
.orderbook-table .ask-row td { color: #f85149; }
.orderbook-table .bid-row td { color: #3fb950; }
.orderbook-table .mid-row td { color: #8b949e; font-weight: 600; border-top: 1px solid #30363d; border-bottom: 1px solid #30363d; }
.chart-canvas { width: 100%; height: 300px; background: #0d1117; border-radius: 6px; margin-top: 12px; }
</style> </style>
</head> </head>
<body> <body>
@@ -74,6 +87,9 @@
<a href="#" data-page="history"> <a href="#" data-page="history">
<span class="icon">&#128203;</span> 과거 기록 조회 <span class="icon">&#128203;</span> 과거 기록 조회
</a> </a>
<a href="#" data-page="stock-info">
<span class="icon">&#128200;</span> 종목 조회
</a>
</nav> </nav>
<div class="sidebar-footer">v0.1.0</div> <div class="sidebar-footer">v0.1.0</div>
</aside> </aside>
@@ -91,7 +107,7 @@
<!-- 실거래 페이지 --> <!-- 실거래 페이지 -->
<div id="page-trading" class="page active"> <div id="page-trading" class="page active">
<div id="auth-alert" class="alert warning"> <div id="auth-alert" class="alert warning">
API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요. API 키가 설정되지 않았거나 유효하지 않습니다. config.yaml 파일에 KIS 설정을 확인하세요.
현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다. 현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다.
</div> </div>
@@ -200,6 +216,65 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 종목 조회 페이지 -->
<div id="page-stock-info" class="page">
<div class="section">
<div class="filter-row">
<div class="form-group">
<label>종목코드</label>
<input id="stock-search-code" type="text" placeholder="005930" />
</div>
<button class="btn primary" onclick="loadStockInfo()">조회</button>
</div>
</div>
<div id="stock-info-content" style="display:none;">
<div class="section">
<div class="stock-header">
<span class="name" id="si-name">-</span>
<span class="code" id="si-code">-</span>
</div>
<div class="stock-price-main" id="si-price">-</div>
<div class="stock-change" id="si-change">-</div>
<div class="info-grid">
<div>
<table>
<tr><th style="width:100px;">시가</th><td id="si-open">-</td></tr>
<tr><th>고가</th><td id="si-high">-</td></tr>
<tr><th>저가</th><td id="si-low">-</td></tr>
<tr><th>거래량</th><td id="si-volume">-</td></tr>
<tr><th>거래대금</th><td id="si-amount">-</td></tr>
</table>
</div>
<div>
<table class="orderbook-table">
<thead><tr><th>#</th><th>매도</th><th>수량</th></tr></thead>
<tbody id="ob-asks"></tbody>
<tr class="mid-row"><td></td><td id="ob-mid">-</td><td></td></tr>
<thead><tr><th>#</th><th>매수</th><th>수량</th></tr></thead>
<tbody id="ob-bids"></tbody>
</table>
</div>
</div>
</div>
<div class="section">
<h2>일봉 차트</h2>
<div style="display:flex;gap:8px;margin-bottom:8px;">
<button class="btn" onclick="loadChart(30)">30일</button>
<button class="btn" onclick="loadChart(60)">60일</button>
<button class="btn" onclick="loadChart(120)">120일</button>
</div>
<canvas id="chart-canvas" class="chart-canvas"></canvas>
</div>
</div>
<div id="stock-info-empty" style="display:none; text-align:center; padding:40px; color:#8b949e;">
종목코드를 입력하고 조회 버튼을 누르세요.
</div>
</div>
</div> </div>
</div> </div>
@@ -336,25 +411,145 @@
function connectWS() { function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`); ws = new WebSocket(`${proto}://${location.host}/ws/realtime`);
ws.onopen = () => { ws.onclose = () => setTimeout(connectWS, 3000);
document.getElementById('ws-status').className = 'connected'; }
document.getElementById('status-text').textContent = '실시간 연결됨';
}; async function loadStatus() {
ws.onclose = () => { try {
document.getElementById('ws-status').className = 'disconnected'; const res = await fetch(API + '/api/dashboard/status');
document.getElementById('status-text').textContent = '연결 끊김 - 재연결 중...'; const data = await res.json();
setTimeout(connectWS, 3000); const el = document.getElementById('ws-status');
}; const text = document.getElementById('status-text');
ws.onmessage = (e) => { if (data.realtime_connected) {
const data = JSON.parse(e.data); el.className = 'connected';
if (data.type === 'price') loadDashboard(); text.textContent = '실시간 연결됨';
}; } else {
el.className = 'disconnected';
text.textContent = data.authenticated ? '연결 끊김' : '인증 필요';
}
} catch (e) { console.error('상태 조회 실패:', e); }
}
async function loadStockInfo() {
const code = document.getElementById('stock-search-code').value.trim();
if (!code) return;
const content = document.getElementById('stock-info-content');
const empty = document.getElementById('stock-info-empty');
content.style.display = 'none';
empty.style.display = 'none';
try {
const [priceRes, obRes] = await Promise.all([
fetch(API + `/api/stocks/${code}/price`),
fetch(API + `/api/stocks/${code}/orderbook`),
]);
if (!priceRes.ok) { empty.style.display = 'block'; empty.textContent = '시세 조회 실패'; return; }
const p = await priceRes.json();
document.getElementById('si-name').textContent = p.stock_name || code;
document.getElementById('si-code').textContent = code;
const priceEl = document.getElementById('si-price');
priceEl.textContent = formatPrice(p.current_price);
priceEl.className = 'stock-price-main ' + (p.change_price >= 0 ? 'positive' : 'negative');
const chEl = document.getElementById('si-change');
const sign = p.change_price >= 0 ? '+' : '';
chEl.textContent = `${sign}${p.change_price.toLocaleString()} (${sign}${p.change_rate.toFixed(2)}%)`;
chEl.className = 'stock-change ' + (p.change_price >= 0 ? 'positive' : 'negative');
document.getElementById('si-open').textContent = formatPrice(p.open_price);
document.getElementById('si-high').textContent = formatPrice(p.high_price);
document.getElementById('si-low').textContent = formatPrice(p.low_price);
document.getElementById('si-volume').textContent = p.volume ? p.volume.toLocaleString() : '-';
document.getElementById('si-amount').textContent = p.trade_amount ? formatPrice(p.trade_amount) : '-';
if (obRes.ok) {
const ob = await obRes.json();
const askTbody = document.getElementById('ob-asks');
askTbody.innerHTML = ob.ask_prices.slice().reverse().map((price, i) => {
const vol = ob.ask_volumes[4 - i] || 0;
return `<tr class="ask-row"><td>${5 - i}</td><td>${price ? price.toLocaleString() : '-'}</td><td>${vol.toLocaleString()}</td></tr>`;
}).join('');
document.getElementById('ob-mid').textContent = p.current_price ? p.current_price.toLocaleString() : '-';
const bidTbody = document.getElementById('ob-bids');
bidTbody.innerHTML = ob.bid_prices.map((price, i) => {
const vol = ob.bid_volumes[i] || 0;
return `<tr class="bid-row"><td>${i + 1}</td><td>${price ? price.toLocaleString() : '-'}</td><td>${vol.toLocaleString()}</td></tr>`;
}).join('');
}
content.style.display = 'block';
loadChart(30);
} catch (e) {
console.error('종목 조회 실패:', e);
empty.style.display = 'block';
empty.textContent = '조회 중 오류가 발생했습니다.';
}
}
async function loadChart(days) {
const code = document.getElementById('stock-search-code').value.trim();
if (!code) return;
try {
const res = await fetch(API + `/api/stocks/${code}/chart?days=${days}`);
const data = await res.json();
if (!data.length) return;
drawChart(data);
} catch (e) { console.error('차트 조회 실패:', e); }
}
function drawChart(data) {
const canvas = document.getElementById('chart-canvas');
const ctx = canvas.getContext('2d');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = 300;
const W = canvas.width, H = canvas.height;
const pad = { top: 20, right: 60, bottom: 30, left: 10 };
const chartW = W - pad.left - pad.right;
const chartH = H - pad.top - pad.bottom;
ctx.clearRect(0, 0, W, H);
const prices = data.flatMap(d => [d.high, d.low]);
const minP = Math.min(...prices);
const maxP = Math.max(...prices);
const range = maxP - minP || 1;
const barW = Math.max(1, (chartW / data.length) - 1);
const gap = chartW / data.length;
const toY = (p) => pad.top + chartH - ((p - minP) / range) * chartH;
ctx.strokeStyle = '#30363d';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = pad.top + (chartH / 4) * i;
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke();
const val = maxP - (range / 4) * i;
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left';
ctx.fillText(val.toLocaleString(), W - pad.right + 4, y + 4);
}
data.forEach((d, i) => {
const x = pad.left + gap * i + gap / 2;
const isUp = d.close >= d.open;
ctx.strokeStyle = isUp ? '#3fb950' : '#f85149';
ctx.fillStyle = isUp ? '#3fb950' : '#f85149';
ctx.beginPath(); ctx.moveTo(x, toY(d.high)); ctx.lineTo(x, toY(d.low)); ctx.stroke();
const oY = toY(d.open), cY = toY(d.close);
const bodyTop = Math.min(oY, cY);
const bodyH = Math.max(Math.abs(oY - cY), 1);
ctx.fillRect(x - barW / 2, bodyTop, barW, bodyH);
});
if (data.length > 0) {
const step = Math.max(1, Math.floor(data.length / 6));
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
for (let i = 0; i < data.length; i += step) {
const x = pad.left + gap * i + gap / 2;
const label = data[i].date;
ctx.fillText(label.slice(4, 6) + '/' + label.slice(6, 8), x, H - 8);
}
}
} }
loadDashboard(); loadDashboard();
loadStatus();
setInterval(loadDashboard, 10000); setInterval(loadDashboard, 10000);
setInterval(loadStatus, 5000);
connectWS(); connectWS();
initHistoryDates(); initHistoryDates();
document.getElementById('stock-search-code').addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadStockInfo();
});
</script> </script>
</body> </body>
</html> </html>
+14 -1
View File
@@ -1,11 +1,24 @@
# StockAutomation 설정 템플릿
# 사용법: 이 파일을 복사하여 config.yaml 로 저장한 뒤 수정하세요.
# cp config.example.yaml config.yaml
#
# 주의: 시크릿 값(app_key, app_secret, account_no, hts_id)은 이 파일에 두지 마세요.
# 반드시 환경변수(.env 또는 서버 환경변수)로 주입하세요.
# KIS_APP_KEY / KIS_APP_SECRET / KIS_ACCOUNT_NO / KIS_ACCOUNT_CODE / KIS_HTS_ID
app: app:
name: "StockAutomation" name: "StockAutomation"
version: "0.1.0" version: "0.1.0"
host: "0.0.0.0"
port: 8000
debug: true
db_path: "./data/stock.db"
log_level: "INFO"
kis: kis:
server_mode: "vps" # vps: 모의투자, real: 실전투자 server_mode: "vps" # vps: 모의투자, real: 실전투자
rate_limit: rate_limit:
requests_per_second: 1.0 # 모의투자: 1.0, 실전투자: 2.0 requests_per_second: 2.0 # 모의투자/실전투자 공통: 초당 2회
retry_delay: 1.5 retry_delay: 1.5
collector: collector:
BIN
View File
Binary file not shown.
View File
+89
View File
@@ -0,0 +1,89 @@
/*****************************************************************************
* 코스닥 종목 코드 파일 구조
****************************************************************************/
typedef struct
{
char mksc_shrn_iscd[SZ_SHRNCODE]; /* 단축코드 */
char stnd_iscd[SZ_STNDCODE]; /* 표준코드 */
char hts_kor_isnm[SZ_KORNAME]; /* 한글종목명 */
char scrt_grp_cls_code[2]; /* 증권그룹구분코드 */
/* ST:주권 MF:증권투자회사 RT:부동산투자회사 */
/* SC:선박투자회사 IF:사회간접자본투융자회사 */
/* DR:주식예탁증서 EW:ELW EF:ETF */
/* SW:신주인수권증권 SR:신주인수권증서 */
/* BC:수익증권 FE:해외ETF FS:외국주권 */
char avls_scal_cls_code[1]; /* 시가총액 규모 구분 코드 유가 */
/* 0:제외 1:KOSDAQ100 2:KOSDAQmid300 3:KOSDAQsmall) */
char bstp_larg_div_code[4]; /* 지수업종 대분류 코드 */
char bstp_medm_div_code[4]; /* 지수 업종 중분류 코드 */
char bstp_smal_div_code[4]; /* 지수업종 소분류 코드 */
char vntr_issu_yn[1]; /* 벤처기업 여부 (Y/N) */
char low_current_yn[1]; /* 저유동성종목 여부 */
char krx_issu_yn[1]; /* KRX 종목 여부 */
char etp_prod_cls_code[1]; /* ETP 상품구분코드 */
/* 0:해당없음 1:투자회사형 2:수익증권형 */
/* 3:ETN 4:손실제한ETN */
char krx100_issu_yn[1]; /* KRX100 종목 여부 (Y/N) */
char krx_car_yn[1]; /* KRX 자동차 여부 */
char krx_smcn_yn[1]; /* KRX 반도체 여부 */
char krx_bio_yn[1]; /* KRX 바이오 여부 */
char krx_bank_yn[1]; /* KRX 은행 여부 */
char etpr_undt_objt_co_yn[1]; /* 기업인수목적회사여부 */
char krx_enrg_chms_yn[1]; /* KRX 에너지 화학 여부 */
char krx_stel_yn[1]; /* KRX 철강 여부 */
char short_over_cls_code[1]; /* 단기과열종목구분코드 0:해당없음 */
/* 1:지정예고 2:지정 3:지정연장(해제연기) */
char krx_medi_cmnc_yn[1]; /* KRX 미디어 통신 여부 */
char krx_cnst_yn[1]; /* KRX 건설 여부 */
char invt_alrm_yn[1]; /* (코스닥)투자주의환기종목여부 */
char krx_scrt_yn [1]; /* KRX 증권 구분 */
char krx_ship_yn [1]; /* KRX 선박 구분 */
char krx_insu_yn[1]; /* KRX섹터지수 보험여부 */
char krx_trnp_yn[1]; /* KRX섹터지수 운송여부 */
char ksq150_nmix_yn[1]; /* KOSDAQ150지수여부 (Y,N) */
char stck_sdpr[9]; /* 주식 기준가 */
char frml_mrkt_deal_qty_unit[5]; /* 정규 시장 매매 수량 단위 */
char ovtm_mrkt_deal_qty_unit[5]; /* 시간외 시장 매매 수량 단위 */
char trht_yn[1]; /* 거래정지 여부 */
char sltr_yn[1]; /* 정리매매 여부 */
char mang_issu_yn[1]; /* 관리 종목 여부 */
char mrkt_alrm_cls_code[2]; /* 시장 경고 구분 코드 (00:해당없음 01:투자주의 */
/* 02:투자경고 03:투자위험) */
char mrkt_alrm_risk_adnt_yn[1]; /* 시장 경고위험 예고 여부 */
char insn_pbnt_yn[1]; /* 불성실 공시 여부 */
char byps_lstn_yn[1]; /* 우회 상장 여부 */
char flng_cls_code[2]; /* 락구분 코드 (00:해당사항없음 01:권리락 */
/* 02:배당락 03:분배락 04:권배락 05:중간배당락 */
/* 06:권리중간배당락 99:기타 */
/* SW,SR,EW는 미해당(SPACE) */
char fcam_mod_cls_code[2]; /* 액면가 변경 구분 코드 (00:해당없음 */
/* 01:액면분할 02:액면병합 99:기타 */
char icic_cls_code[2]; /* 증자 구분 코드 (00:해당없음 01:유상증자 */
/* 02:무상증자 03:유무상증자 99:기타) */
char marg_rate[3]; /* 증거금 비율 */
char crdt_able[1]; /* 신용주문 가능 여부 */
char crdt_days[3]; /* 신용기간 */
char prdy_vol[12]; /* 전일 거래량 */
char stck_fcam[12]; /* 주식 액면가 */
char stck_lstn_date[8]; /* 주식 상장 일자 */
char lstn_stcn[15]; /* 상장 주수(천) */
char cpfn[21]; /* 자본금 */
char stac_month[2]; /* 결산 월 */
char po_prc[7]; /* 공모 가격 */
char prst_cls_code[1]; /* 우선주 구분 코드 (0:해당없음(보통주) */
/* 1:구형우선주 2:신형우선주 */
char ssts_hot_yn[1]; /* 공매도과열종목여부 */
char stange_runup_yn[1]; /* 이상급등종목여부 */
char krx300_issu_yn[1]; /* KRX300 종목 여부 (Y/N) */
char sale_account[9]; /* 매출액 */
char bsop_prfi[9]; /* 영업이익 */
char op_prfi[9]; /* 경상이익 */
char thtr_ntin[5]; /* 당기순이익 */
char roe[9]; /* ROE(자기자본이익률) */
char base_date[8]; /* 기준년월 */
char prdy_avls_scal[9]; /* 전일기준 시가총액 (억) */
char grp_code[3]; /* 그룹사 코드 */
char co_crdt_limt_over_yn[1]; /* 회사신용한도초과여부 */
char secu_lend_able_yn[1]; /* 담보대출가능여부 */
char stln_able_yn[1]; /* 대주가능여부 */
} ST_KSQ_CODE;
+99
View File
@@ -0,0 +1,99 @@
/*****************************************************************************
* 코스피 종목 코드 파일 구조
****************************************************************************/
typedef struct
{
char mksc_shrn_iscd[SZ_SHRNCODE]; /* 단축코드 */
char stnd_iscd[SZ_STNDCODE]; /* 표준코드 */
char hts_kor_isnm[SZ_KORNAME]; /* 한글종목명 */
char scrt_grp_cls_code[2]; /* 증권그룹구분코드 */
/* ST:주권 MF:증권투자회사 RT:부동산투자회사 */
/* SC:선박투자회사 IF:사회간접자본투융자회사 */
/* DR:주식예탁증서 EW:ELW EF:ETF */
/* SW:신주인수권증권 SR:신주인수권증서 */
/* BC:수익증권 FE:해외ETF FS:외국주권 */
char avls_scal_cls_code[1]; /* 시가총액 규모 구분 코드 유가 */
/* (0:제외 1:대 2:중 3:소) */
char bstp_larg_div_code[4]; /* 지수 업종 대분류 코드 */
char bstp_medm_div_code[4]; /* 지수 업종 중분류 코드 */
char bstp_smal_div_code[4]; /* 지수 업종 소분류 코드 */
char mnin_cls_code_yn[1]; /* 제조업 구분 코드 (Y/N) */
char low_current_yn[1]; /* 저유동성종목 여부 */
char sprn_strr_nmix_issu_yn[1]; /* 지배 구조 지수 종목 여부 (Y/N) */
char kospi200_apnt_cls_code[1]; /* KOSPI200 섹터업종(20110401 변경됨) */
/* 0:해당없음 1:건설 2:중공업 3:철강소재 */
/* 4:에너지화학 5:정보통신 6:금융 7:필수소비재 */
/* 8:자유소비재 9:산업재 */
/* A:건강관리 B:커뮤니케이션서비스 */
char kospi100_issu_yn[1]; /* KOSPI100여부 */
char kospi50_issu_yn[1]; /* KOSPI50 종목 여부 */
char krx_issu_yn[1]; /* KRX 종목 여부 */
char etp_prod_cls_code[1]; /* ETP 상품구분코드 */
/* 0:해당없음 1:투자회사형 2:수익증권형 */
/* 3:ETN 4:손실제한ETN 5:상장형수익증권 */
char elw_pblc_yn[1]; /* ELW 발행여부 (Y/N) */
char krx100_issu_yn[1]; /* KRX100 종목 여부 (Y/N) */
char krx_car_yn[1]; /* KRX 자동차 여부 */
char krx_smcn_yn[1]; /* KRX 반도체 여부 */
char krx_bio_yn[1]; /* KRX 바이오 여부 */
char krx_bank_yn[1]; /* KRX 은행 여부 */
char etpr_undt_objt_co_yn[1]; /* 기업인수목적회사여부 */
char krx_enrg_chms_yn[1]; /* KRX 에너지 화학 여부 */
char krx_stel_yn[1]; /* KRX 철강 여부 */
char short_over_cls_code[1]; /* 단기과열종목구분코드 0:해당없음 */
/* 1:지정예고 2:지정 3:지정연장(해제연기) */
char krx_medi_cmnc_yn[1]; /* KRX 미디어 통신 여부 */
char krx_cnst_yn[1]; /* KRX 건설 여부 */
char krx_fnnc_svc_yn[1]; /* 삭제됨(20151218) */
char krx_scrt_yn [1]; /* KRX 증권 구분 */
char krx_ship_yn [1]; /* KRX 선박 구분 */
char krx_insu_yn[1]; /* KRX섹터지수 보험여부 */
char krx_trnp_yn[1]; /* KRX섹터지수 운송여부 */
char sri_nmix_yn[1]; /* SRI 지수여부 (Y,N) */
char stck_sdpr[9]; /* 주식 기준가 */
char frml_mrkt_deal_qty_unit[5]; /* 정규 시장 매매 수량 단위 */
char ovtm_mrkt_deal_qty_unit[5]; /* 시간외 시장 매매 수량 단위 */
char trht_yn[1]; /* 거래정지 여부 */
char sltr_yn[1]; /* 정리매매 여부 */
char mang_issu_yn[1]; /* 관리 종목 여부 */
char mrkt_alrm_cls_code[2]; /* 시장 경고 구분 코드 (00:해당없음 01:투자주의 */
/* 02:투자경고 03:투자위험 */
char mrkt_alrm_risk_adnt_yn[1]; /* 시장 경고위험 예고 여부 */
char insn_pbnt_yn[1]; /* 불성실 공시 여부 */
char byps_lstn_yn[1]; /* 우회 상장 여부 */
char flng_cls_code[2]; /* 락구분 코드 (00:해당사항없음 01:권리락 */
/* 02:배당락 03:분배락 04:권배락 05:중간배당락 */
/* 06:권리중간배당락 99:기타 */
/* S?W,SR,EW는 미해당(SPACE) */
char fcam_mod_cls_code[2]; /* 액면가 변경 구분 코드 (00:해당없음 */
/* 01:액면분할 02:액면병합 99:기타 */
char icic_cls_code[2]; /* 증자 구분 코드 (00:해당없음 01:유상증자 */
/* 02:무상증자 03:유무상증자 99:기타) */
char marg_rate[3]; /* 증거금 비율 */
char crdt_able[1]; /* 신용주문 가능 여부 */
char crdt_days[3]; /* 신용기간 */
char prdy_vol[12]; /* 전일 거래량 */
char stck_fcam[12]; /* 주식 액면가 */
char stck_lstn_date[8]; /* 주식 상장 일자 */
char lstn_stcn[15]; /* 상장 주수(천) */
char cpfn[21]; /* 자본금 */
char stac_month[2]; /* 결산 월 */
char po_prc[7]; /* 공모 가격 */
char prst_cls_code[1]; /* 우선주 구분 코드 (0:해당없음(보통주) */
/* 1:구형우선주 2:신형우선주 */
char ssts_hot_yn[1]; /* 공매도과열종목여부 */
char stange_runup_yn[1]; /* 이상급등종목여부 */
char krx300_issu_yn[1]; /* KRX300 종목 여부 (Y/N) */
char kospi_issu_yn[1]; /* KOSPI여부 */
char sale_account[9]; /* 매출액 */
char bsop_prfi[9]; /* 영업이익 */
char op_prfi[9]; /* 경상이익 */
char thtr_ntin[5]; /* 당기순이익 */
char roe[9]; /* ROE(자기자본이익률) */
char base_date[8]; /* 기준년월 */
char prdy_avls_scal[9]; /* 전일기준 시가총액 (억) */
char grp_code[3]; /* 그룹사 코드 */
char co_crdt_limt_over_yn[1]; /* 회사신용한도초과여부 */
char secu_lend_able_yn[1]; /* 담보대출가능여부 */
char stln_able_yn[1]; /* 대주가능여부 */
} ST_KSP_CODE;
-1
View File
@@ -45,7 +45,6 @@ typing_extensions==4.16.0
tzlocal==5.4.4 tzlocal==5.4.4
urllib3==2.7.0 urllib3==2.7.0
uvicorn==0.51.0 uvicorn==0.51.0
uvloop==0.22.1
watchfiles==1.2.0 watchfiles==1.2.0
websocket-client==1.9.0 websocket-client==1.9.0
websockets==16.1 websockets==16.1