Files
kis_bot/kis_trader/database/db_manager.py
Hwang f61c471aac 브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
2026-05-05 21:04:17 +09:00

547 lines
23 KiB
Python

"""
kis_trader/database/db_manager.py — 통합 DB 관리자
==================================================
기존 ``database.TradeDB``(MariaDB 2,000줄)를 **그대로 재사용**하고,
본 모듈에서는 주문(order_id=ODNO) 단위 추적을 위한 ``orders`` 테이블을
추가·관리한다.
핵심 설계:
1. ODNO(한투 주문번호)를 PK 로 사용 → DB 레벨에서 중복 주문 차단.
2. UNIQUE(strategy_id, code, order_date, side) 인덱스 → 같은 전략이
같은 날 같은 종목·같은 방향으로 중복 주문 시도 시 서버단 차단.
3. 전략별 filter → 꼬리잡기봇과 스캘핑봇의 "남의 집 물건 팔기" 방지.
본 모듈은 TradeDB 를 수정하지 않고 래핑한다 (원본 코드 존중 규칙).
"""
from __future__ import annotations
import datetime
import logging
from typing import Dict, List, Optional
from database import TradeDB # 프로젝트 루트 모듈
logger = logging.getLogger("kis_trader.db")
# 싱글톤: 프로세스 내 TradeDBExt 재사용 (연결 낭비 방지)
_SINGLETON: Optional["TradeDBExt"] = None
def get_db() -> "TradeDBExt":
global _SINGLETON
if _SINGLETON is None:
_SINGLETON = TradeDBExt()
return _SINGLETON
class TradeDBExt:
"""
기존 TradeDB 를 합성(Composition)한 래퍼. ``self.raw`` 로 원본 객체 접근.
``orders`` 테이블 생성/마이그레이션을 책임지고, 주문 이력을 기록/조회하는
메서드를 추가로 제공한다.
"""
def __init__(self):
self.raw = TradeDB()
self.conn = self.raw.conn
self._ensure_orders_table()
# ------------------------------------------------------------------
# 테이블 마이그레이션: orders
# ------------------------------------------------------------------
def _ensure_orders_table(self) -> None:
"""
orders 테이블:
ord_no : 한투 주문번호(ODNO). PK — 서버단 중복 차단.
strategy_id : SCALP_RSI_REVERSAL, SHORT_ANT_SHAKING 등
code/name : 종목코드/명
side : BUY / SELL
qty : 주문 수량
price : 주문 시 참고가 (시장가=0 일 수 있음)
filled_qty : 실제 체결 수량
filled_avg_price : 평균 체결가
status : SUBMITTED / FILLED / PARTIAL / REJECTED / CANCELLED
msg_cd/msg1 : 한투 응답 메시지(실패 원인 추적)
submitted_at : 주문 접수 시각
filled_at : 체결 확인 시각
UNIQUE(strategy_id, code, ord_date, side):
같은 전략·같은 종목·같은 날짜·같은 방향 중복 주문 시도를 서버단 차단.
(정정/취소는 별도 ODNO 로 인입되므로 구분됨)
"""
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS orders (
ord_no VARCHAR(30) NOT NULL PRIMARY KEY,
strategy_id VARCHAR(40) NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
side VARCHAR(8) NOT NULL,
qty INT NOT NULL,
price DOUBLE DEFAULT 0,
filled_qty INT NOT NULL DEFAULT 0,
filled_avg_price DOUBLE DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED',
msg_cd VARCHAR(20) DEFAULT NULL,
msg1 VARCHAR(300) DEFAULT NULL,
ord_date VARCHAR(10) NOT NULL,
submitted_at VARCHAR(30) NOT NULL,
filled_at VARCHAR(30) DEFAULT NULL,
raw_json MEDIUMTEXT DEFAULT NULL,
INDEX idx_strategy_date (strategy_id, ord_date),
INDEX idx_code_date (code, ord_date),
UNIQUE KEY uq_strategy_code_side_date (strategy_id, code, side, ord_date)
) CHARACTER SET utf8mb4
""")
logger.info("📊 orders 테이블 확인/생성 완료")
except Exception as e:
logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e)
# ------------------------------------------------------------------
# orders CRUD
# ------------------------------------------------------------------
def insert_order(
self,
*,
ord_no: str,
strategy_id: str,
code: str,
name: str,
side: str,
qty: int,
price: float = 0.0,
status: str = "SUBMITTED",
msg_cd: Optional[str] = None,
msg1: Optional[str] = None,
raw_json: Optional[str] = None,
) -> bool:
"""
주문 기록 INSERT. PK(ord_no) 중복이면 False 반환 (서버단 차단).
side: 'BUY' | 'SELL'
"""
side = (side or "").upper()
assert side in ("BUY", "SELL"), f"side must be BUY|SELL, got {side}"
now = datetime.datetime.now()
try:
with self.conn:
self.conn.execute(
"""
INSERT INTO orders (
ord_no, strategy_id, code, name, side, qty, price,
status, msg_cd, msg1, ord_date, submitted_at, raw_json
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
ord_no, strategy_id, code, name, side, qty, price,
status, msg_cd, msg1,
now.strftime("%Y-%m-%d"),
now.strftime("%Y-%m-%d %H:%M:%S"),
raw_json,
),
)
return True
except Exception as e:
# 중복 PK(1062) / 유니크 인덱스 위반은 정상적 차단으로 간주
msg = str(e)
if "1062" in msg or "Duplicate entry" in msg:
logger.warning(
"⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s (이미 DB 존재)",
strategy_id, code, side, ord_no,
)
return False
logger.error("insert_order 실패 (ord_no=%s): %s", ord_no, e)
return False
def update_order_fill(
self,
*,
ord_no: str,
filled_qty: int,
filled_avg_price: float,
status: str = "FILLED",
) -> bool:
"""주문 체결 확인 후 체결가/체결수량/상태 갱신."""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
with self.conn:
self.conn.execute(
"""
UPDATE orders
SET filled_qty=%s, filled_avg_price=%s, status=%s, filled_at=%s
WHERE ord_no=%s
""",
(filled_qty, filled_avg_price, status, now, ord_no),
)
return True
except Exception as e:
logger.error("update_order_fill 실패 (%s): %s", ord_no, e)
return False
def mark_order_rejected(
self, *, ord_no: str, msg_cd: str = "", msg1: str = ""
) -> None:
"""주문 실패/거부 시 상태 REJECTED 처리."""
try:
with self.conn:
self.conn.execute(
"""
UPDATE orders SET status='REJECTED', msg_cd=%s, msg1=%s
WHERE ord_no=%s
""",
(msg_cd, msg1, ord_no),
)
except Exception as e:
logger.debug("mark_order_rejected 실패 (%s): %s", ord_no, e)
def get_order_by_odno(self, ord_no: str) -> Optional[Dict]:
try:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s", (ord_no,)
).fetchone()
return dict(row) if row else None
except Exception as e:
logger.error("get_order_by_odno 실패: %s", e)
return None
def get_orders_today(
self, strategy_id: Optional[str] = None
) -> List[Dict]:
today = datetime.datetime.now().strftime("%Y-%m-%d")
try:
if strategy_id:
rows = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s AND strategy_id=%s
ORDER BY submitted_at DESC
""",
(today, strategy_id),
).fetchall()
else:
rows = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
ORDER BY submitted_at DESC
""",
(today,),
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.error("get_orders_today 실패: %s", e)
return []
# ------------------------------------------------------------------
# 조건검색 유니버스 스냅샷 (백테스트용 이력)
# ------------------------------------------------------------------
# 설계 원칙:
# - 5분 슬롯 스냅샷은 정보 손실이 크다(그 사이 들락날락한 종목 유실).
# - 따라서 **변동(ENTER/EXIT) 발생 tick 마다 풀 스냅샷**을 저장한다.
# - 변동 없는 tick 은 저장하지 않는다 (공간 절약).
# - 각 스냅샷은 같은 ``event_time`` 을 공유하는 N 행으로 표현.
# - 백테스트 시 특정 시점 유니버스 = 그 시점 이전 최근 event_time.
_HISTORY_MIGRATED = False
def _ensure_history_columns(self) -> None:
"""
target_candidates_history 에 필요한 확장 컬럼을 자동 추가:
* strategy_id — 전략 구분 (기존 키움 스캐너 행은 NULL)
* event_time — 스냅샷 시각 (초 단위 'YYYY-MM-DD HH:MM:SS')
"""
if TradeDBExt._HISTORY_MIGRATED:
return
try:
cols = self.conn.get_columns("target_candidates_history")
if "strategy_id" not in cols:
self.conn.execute(
"ALTER TABLE target_candidates_history "
"ADD COLUMN strategy_id VARCHAR(30) DEFAULT NULL"
)
logger.info("📌 target_candidates_history.strategy_id 컬럼 추가")
if "event_time" not in cols:
self.conn.execute(
"ALTER TABLE target_candidates_history "
"ADD COLUMN event_time VARCHAR(19) DEFAULT NULL"
)
logger.info("📌 target_candidates_history.event_time 컬럼 추가")
# 인덱스 — 백테스트 조회 속도 확보
for idx_name, idx_def in (
("idx_strategy_event",
"(strategy_id, event_time)"),
):
try:
self.conn.execute(
f"CREATE INDEX {idx_name} "
f"ON target_candidates_history {idx_def}"
)
except Exception as e:
# 이미 존재 / 버전 미지원 → 무시
if "1061" not in str(e) and "Duplicate" not in str(e):
logger.debug("idx %s 생성 실패: %s", idx_name, e)
except Exception as e:
logger.debug("history 컬럼 마이그레이션: %s", e)
TradeDBExt._HISTORY_MIGRATED = True
def insert_condition_universe_snapshot(
self,
*,
strategy_id: str,
event_time: str,
items: List[Dict],
slot_key: Optional[str] = None,
) -> int:
"""
조건검색 변동(ENTER/EXIT) 발생 tick 마다 호출되는 **풀 스냅샷** 저장.
같은 ``event_time`` 으로 들어온 N 행 = 그 시점의 유니버스 전체.
Args:
strategy_id : 'SCALP' | 'SHORT' | 'BREAKOUT' ...
event_time : 'YYYY-MM-DD HH:MM:SS' (초 단위). 백테스트 기준 시각.
items : [{"code": "...", "name": "..."}, ...] — 현재 유니버스 전체
slot_key : (선택) 기존 5분 슬롯 키. 과거 대시보드/쿼리 호환용.
Returns: 실제 INSERT 된 행수
"""
if not items or not event_time:
return 0
self._ensure_history_columns()
# slot_key 기본값 — 기존 스키마 NOT NULL 이므로 최소한 값 채움
if not slot_key:
# event_time 'YYYY-MM-DD HH:MM:SS' → 'YYYYMMDDHHMM' (5분 단위 반올림 X, 그대로)
try:
dp = event_time.replace("-", "").replace(":", "").replace(" ", "")
slot_key = dp[:12] # YYYYMMDDHHMM
except Exception:
slot_key = event_time[:12]
inserted = 0
try:
with self.conn:
# 동일 (전략, event_time) 키로 재호출되면 이전 것 지우고 재기록
# (사실상 중복 호출 방지용 — ConditionSearchManager 가 tick 단위 유니크)
self.conn.execute(
"DELETE FROM target_candidates_history "
"WHERE strategy_id=%s AND event_time=%s",
(strategy_id, event_time),
)
for it in items:
code = (it.get("code") or "").strip()
if not code:
continue
name = (it.get("name") or code)[:100]
try:
self.conn.execute(
"""
INSERT INTO target_candidates_history
(slot_key, scan_time, code, name, score, price,
market, sector, theme, strategy_id, event_time)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(slot_key, event_time, code, name, 0.0, 0.0,
"Q", "", "", strategy_id, event_time),
)
inserted += 1
except Exception as e:
logger.debug("history insert 실패(%s/%s): %s",
strategy_id, code, e)
except Exception as e:
logger.warning("history 스냅샷 저장 실패: %s", e)
return inserted
# 범용 별칭 — 유니버스 소스가 condition 이든 ranking 이든 같은 테이블을 공유
# (함수명 혼동 줄이기 위한 alias. 기존 호출부는 그대로 동작)
def insert_universe_snapshot(
self,
*,
strategy_id: str,
event_time: str,
items: List[Dict],
slot_key: Optional[str] = None,
) -> int:
return self.insert_condition_universe_snapshot(
strategy_id=strategy_id, event_time=event_time,
items=items, slot_key=slot_key,
)
# ------------------------------------------------------------------
# 백테스트 헬퍼: 특정 시점의 유니버스 복원
# ------------------------------------------------------------------
def get_universe_at(
self, *, strategy_id: str, at_time: str
) -> List[Dict]:
"""
``at_time`` ('YYYY-MM-DD HH:MM:SS') 시점에 봇이 보던 유니버스를 복원.
= 그 시각 이전의 가장 최근 event_time 스냅샷.
"""
self._ensure_history_columns()
try:
row = self.conn.execute(
"""
SELECT MAX(event_time) AS et
FROM target_candidates_history
WHERE strategy_id=%s AND event_time <= %s
""",
(strategy_id, at_time),
).fetchone()
et = (row or {}).get("et") if row else None
if not et:
return []
rows = self.conn.execute(
"""
SELECT code, name FROM target_candidates_history
WHERE strategy_id=%s AND event_time=%s
ORDER BY code
""",
(strategy_id, et),
).fetchall()
return [{"code": r["code"], "name": r["name"]} for r in rows]
except Exception as e:
logger.error("get_universe_at 실패: %s", e)
return []
def iter_universe_events(
self,
*,
strategy_id: str,
start_time: str,
end_time: str,
) -> List[Dict]:
"""
``[start_time, end_time]`` 구간의 모든 스냅샷 이벤트를 시간순 반환.
각 이벤트 = {"event_time": ..., "codes": [...]}.
백테스트 루프에서 시점별 유니버스를 순회할 때 사용.
"""
self._ensure_history_columns()
try:
rows = self.conn.execute(
"""
SELECT event_time, code, name
FROM target_candidates_history
WHERE strategy_id=%s
AND event_time BETWEEN %s AND %s
ORDER BY event_time, code
""",
(strategy_id, start_time, end_time),
).fetchall()
grouped: Dict[str, List[Dict]] = {}
for r in rows:
et = r["event_time"]
grouped.setdefault(et, []).append(
{"code": r["code"], "name": r["name"]}
)
return [
{"event_time": et, "items": items}
for et, items in sorted(grouped.items())
]
except Exception as e:
logger.error("iter_universe_events 실패: %s", e)
return []
def get_universe_by_candle_time(
self,
*,
strategy_id: str,
start_ymd: str,
end_ymd: str,
) -> Dict[str, List[str]]:
"""
백테스트 편의용 — 1분봉 캔들 시각(YYYYMMDDHHMM)을 키로 하는 유니버스 dict.
실매매는 10초 주기로 REST 를 돌려 변동 tick 마다 초단위 ``event_time``
(YYYY-MM-DD HH:MM:SS) 으로 ``target_candidates_history`` 에 적재한다.
반면 백테스트 엔진은 1분봉 단위로 돌아가므로, 각 캔들에 대해
**그 캔들의 종가 형성 시각 (HH:MM:59) 이전의 가장 최근 스냅샷** 을
선택해 "그 시점에 봇이 보던 유니버스" 를 재현한다.
Args:
strategy_id: 'SCALP' | 'SHORT' | 'BREAKOUT'
start_ymd: 시작일 YYYYMMDD
end_ymd: 종료일 YYYYMMDD
Returns:
``{candle_time(YYYYMMDDHHMM): [code, ...]}`` —
해당 1분 캔들 종료 시점에 유효했던 유니버스 코드 리스트.
스냅샷이 하나도 없으면 빈 dict.
"""
self._ensure_history_columns()
# 조회 범위: 전일 마지막 스냅샷도 포함하기 위해 시작일 00:00:00 이전 1건은
# 엔진 쪽에서 "이전에 유효했던 유니버스" 로 물려받는 게 자연스럽다.
# 단순화를 위해 start_ymd 00:00:00 ~ end_ymd 23:59:59 범위로 쿼리.
start_time = (
f"{start_ymd[:4]}-{start_ymd[4:6]}-{start_ymd[6:8]} 00:00:00"
)
end_time = (
f"{end_ymd[:4]}-{end_ymd[4:6]}-{end_ymd[6:8]} 23:59:59"
)
events = self.iter_universe_events(
strategy_id=strategy_id,
start_time=start_time,
end_time=end_time,
)
if not events:
return {}
# 각 스냅샷의 event_time 을 'YYYYMMDDHHMMSS' (14자리 정수비교용) 으로 정규화.
# event_time 문자열은 이미 시간순 정렬됨 (iter_universe_events).
normalized: List[tuple] = []
for ev in events:
et = str(ev.get("event_time") or "")
if len(et) < 19:
continue
et_key = (
et[0:4] + et[5:7] + et[8:10]
+ et[11:13] + et[14:16] + et[17:19]
) # YYYYMMDDHHMMSS
codes = [it["code"] for it in ev.get("items", []) if it.get("code")]
normalized.append((et_key, codes))
if not normalized:
return {}
out: Dict[str, List[str]] = {}
# candle_time 은 분단위 (YYYYMMDDHHMM). 각 캔들의 "종가 형성 시점" 은
# 그 분의 59초로 본다 → candle_end_key = candle_time + "59".
# 스냅샷 포인터를 전진하며 O(N+M) 으로 매칭.
ev_idx = 0
n_ev = len(normalized)
active_codes: List[str] = []
# 첫 이벤트 시각을 분단위로 내림 → 그 이전 캔들은 유니버스 없음
first_ev_key = normalized[0][0]
# 날짜별 장중 시간대 (09:00~15:30) 1분 단위로 캔들 시각 생성.
# DB 에 없는 시각도 dict 에 key 가 생기면 메모리 낭비이므로, 엔진이 실제
# 요청하는 캔들 시각에 대해서만 값을 반환하도록 **lazy** 하게 넘기고 싶지만,
# 엔진 쪽은 dict lookup 만 하므로 여기서 미리 만들어 주는 편이 단순.
from datetime import datetime, timedelta
d0 = datetime.strptime(start_ymd, "%Y%m%d").date()
d1 = datetime.strptime(end_ymd, "%Y%m%d").date()
day = d0
while day <= d1:
# 장중 대략 범위: 08:00 ~ 16:00 (pre/post 여유)
t = datetime.combine(day, datetime.min.time()).replace(hour=8)
t_end = datetime.combine(day, datetime.min.time()).replace(hour=16)
while t <= t_end:
candle_time = t.strftime("%Y%m%d%H%M")
candle_end_key = candle_time + "59" # YYYYMMDDHHMMSS
# 그 이전 또는 같은 시각의 가장 최근 event_time 까지 포인터 전진
while ev_idx < n_ev and normalized[ev_idx][0] <= candle_end_key:
active_codes = normalized[ev_idx][1]
ev_idx += 1
if active_codes and candle_end_key >= first_ev_key:
out[candle_time] = active_codes
t += timedelta(minutes=1)
day += timedelta(days=1)
return out
# ------------------------------------------------------------------
# TradeDB 위임 (원본 API 그대로 사용)
# ------------------------------------------------------------------
def __getattr__(self, name):
"""TradeDBExt 에 없는 속성/메서드는 self.raw 에 위임."""
return getattr(self.raw, name)