Files
kis_bot/kis_trader/database/db_manager.py
Your Name 36a3e2b4a1 feat: Enhance trading system with new permanent subscription features and order book management
Changes:
- Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically.
- Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database.
- Introduced a mechanism to handle master subscription states, improving the management of subscription statuses.
- Updated the database schema to include new fields for managing subscription states and order book filtering.

Impact:
- These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies.

히스토리 align 제거 븅신같은 초기설계 아예 제거
진입모드에 구멍메움
호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
2026-08-15 23:01:14 +09:00

875 lines
37 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 : 체결 확인 시각
PK: 서로게이트 id(auto_increment). 중복 차단은
(ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE 로 한정한다.
(구) UNIQUE(strategy,code,side,ord_date) 는 당일 재매수·부분체결 재주문 시
HTS 에는 체결됐는데 orders/active_trades 미기록 버그 유발 → 제거.
※ ord_no 단독 PK 였던 과거엔 "다른 영업일·다른 전략" 주문이 같은 ODNO 를
받아도(모의투자 서버가 과거 이미 쓴 ODNO 를 재발급하는 사례 확인됨) DB가
전역 중복으로 오판 → insert 실패 → 그 매수 시도를 통째로 포기해
active_trades 미기록(장마감 고아복구 전까지 손절 무방비) 사고가 발생했다.
복합 UNIQUE 로 좁혀 "진짜 같은 날·같은 전략·같은 종목·같은 방향의 재전송"
차단하고, 그 외 ODNO 재사용은 정상 신규 주문으로 기록되게 한다.
"""
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS orders (
id BIGINT NOT NULL AUTO_INCREMENT,
ord_no VARCHAR(30) NOT NULL,
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,
PRIMARY KEY (id),
UNIQUE KEY uq_ord_no_ctx (ord_no, strategy_id, code, side, ord_date),
INDEX idx_strategy_date (strategy_id, ord_date),
INDEX idx_code_date (code, ord_date)
) CHARACTER SET utf8mb4
""")
self._migrate_orders_drop_daily_side_unique()
self._migrate_orders_pk_scope()
logger.info("📊 orders 테이블 확인/생성 완료")
except Exception as e:
logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e)
def _migrate_orders_drop_daily_side_unique(self) -> None:
"""당일 1회 매수 UNIQUE 제거 — 재진입·삼성전자 누적매수 DB 미기록 방지."""
try:
rows = self.conn.execute(
"SHOW INDEX FROM orders WHERE Key_name = %s",
("uq_strategy_code_side_date",),
).fetchall()
if rows:
self.conn.execute(
"ALTER TABLE orders DROP INDEX uq_strategy_code_side_date"
)
logger.info(
"✅ orders.uq_strategy_code_side_date 제거 "
"(당일 재매수 시 DB 동기화 가능)"
)
except Exception as e:
logger.debug("orders UNIQUE 마이그레이션 스킵: %s", e)
def _migrate_orders_pk_scope(self) -> None:
"""기존(구버전) 테이블: ord_no 단독 PK → surrogate id PK + 복합 UNIQUE 로 전환.
모의투자 서버의 ODNO 재사용 버그로 다른 날짜/전략 주문이 같은 ODNO 를
받으면 종전 PK(ord_no 단독)에서는 무조건 "중복"으로 차단됐다. 이미
운영 중인 DB(구 스키마)를 새 스키마로 안전 전환한다.
"""
try:
cols = self.conn.get_columns("orders")
if "id" not in cols:
self.conn.execute(
"ALTER TABLE orders "
"ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT FIRST, "
"DROP PRIMARY KEY, "
"ADD PRIMARY KEY (id)"
)
logger.info(
"✅ orders.id(surrogate PK) 추가 — ord_no 단독 PK 제거"
)
rows = self.conn.execute(
"SHOW INDEX FROM orders WHERE Key_name = %s",
("uq_ord_no_ctx",),
).fetchall()
if not rows:
self.conn.execute(
"ALTER TABLE orders ADD UNIQUE INDEX uq_ord_no_ctx "
"(ord_no, strategy_id, code, side, ord_date)"
)
logger.info(
"✅ orders.uq_ord_no_ctx 추가 — 중복 판정을 "
"(ord_no, strategy_id, code, side, ord_date) 로 한정"
)
except Exception as e:
logger.warning("orders PK 범위 마이그레이션 실패(무시·폴백): %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.
중복 판정은 (ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE 로
한정 — 다른 날짜·다른 전략·다른 종목이 같은 ODNO 를 받아도(모의투자
서버 ODNO 재사용 버그) 오탐 차단하지 않는다. 실패 시 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,
strategy_id: str,
code: str,
filled_qty: int,
filled_avg_price: float,
status: str = "FILLED",
ord_date: Optional[str] = None,
) -> bool:
"""주문 체결 확인 후 체결가/체결수량/상태 갱신.
strategy_id·code·ord_date 로 정확히 그 행만 갱신 — ODNO 재사용 시
다른 날짜/전략의 동일 ODNO 행을 잘못 덮어쓰는 사고 방지.
"""
now_dt = datetime.datetime.now()
now = now_dt.strftime("%Y-%m-%d %H:%M:%S")
od = ord_date or now_dt.strftime("%Y-%m-%d")
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 AND strategy_id=%s AND code=%s AND ord_date=%s
""",
(filled_qty, filled_avg_price, status, now,
ord_no, strategy_id, code, od),
)
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,
strategy_id: str,
code: str,
msg_cd: str = "",
msg1: str = "",
ord_date: Optional[str] = None,
) -> None:
"""주문 실패/거부 시 상태 REJECTED 처리 (strategy_id·code·ord_date 로 행 한정)."""
od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
try:
with self.conn:
self.conn.execute(
"""
UPDATE orders SET status='REJECTED', msg_cd=%s, msg1=%s
WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s
""",
(msg_cd, msg1, ord_no, strategy_id, code, od),
)
except Exception as e:
logger.debug("mark_order_rejected 실패 (%s): %s", ord_no, e)
def update_order_status(
self,
*,
ord_no: str,
strategy_id: str,
code: str,
status: str,
ord_date: Optional[str] = None,
) -> bool:
"""체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date 로 행 한정)."""
od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d")
try:
with self.conn:
self.conn.execute(
"UPDATE orders SET status=%s "
"WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s",
(status, ord_no, strategy_id, code, od),
)
return True
except Exception as e:
logger.error("update_order_status 실패 (%s): %s", ord_no, e)
return False
def get_pending_fill_orders(self) -> List[Dict]:
"""
당일 미확인·부분체결 주문 (체결 qty < 주문 qty).
heartbeat ``poll_pending_fills`` 재조회용.
"""
today = datetime.datetime.now().strftime("%Y-%m-%d")
try:
rows = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL')
AND COALESCE(filled_qty, 0) < qty
ORDER BY submitted_at ASC
""",
(today,),
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.error("get_pending_fill_orders 실패: %s", e)
return []
def get_pending_sell_order(
self, strategy_id: str, code: str
) -> Optional[Dict]:
"""동일 전략·종목 미체결 매도 1건 (중복 주문 방지용)."""
today = datetime.datetime.now().strftime("%Y-%m-%d")
try:
row = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
AND strategy_id=%s
AND code=%s
AND side='SELL'
AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL')
AND COALESCE(filled_qty, 0) < qty
ORDER BY submitted_at DESC
LIMIT 1
""",
(today, strategy_id, code),
).fetchone()
return dict(row) if row else None
except Exception as e:
logger.error("get_pending_sell_order 실패 (%s/%s): %s", strategy_id, code, e)
return None
def get_pending_buy_order(
self, strategy_id: str, code: str
) -> Optional[Dict]:
"""동일 전략·종목 미체결 매수 1건 (중복 주문 방지용).
체결확인 API(inquire-daily-ccld) 장애로 fill 미확인(PENDING_FILL) 상태일 때,
전략 루프가 같은 종목을 매 턴 재주문하는 폭주를 막는다.
만료 시 poll_pending_fills 가 status 를 CANCELLED 로 바꿔 자동 해제된다.
"""
today = datetime.datetime.now().strftime("%Y-%m-%d")
try:
row = self.conn.execute(
"""
SELECT * FROM orders
WHERE ord_date=%s
AND strategy_id=%s
AND code=%s
AND side='BUY'
AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL')
AND COALESCE(filled_qty, 0) < qty
ORDER BY submitted_at DESC
LIMIT 1
""",
(today, strategy_id, code),
).fetchone()
return dict(row) if row else None
except Exception as e:
logger.error("get_pending_buy_order 실패 (%s/%s): %s", strategy_id, code, e)
return None
def get_order_by_odno(
self,
ord_no: str,
*,
strategy_id: Optional[str] = None,
code: Optional[str] = None,
) -> Optional[Dict]:
"""ODNO 로 주문 조회. ODNO 는 더 이상 전역 유일하지 않으므로
(모의투자 ODNO 재사용 버그) strategy_id·code 를 함께 주면 정확히
그 행만, 안 주면 최신 순 1건을 반환한다."""
try:
if strategy_id and code:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s AND strategy_id=%s AND code=%s "
"ORDER BY submitted_at DESC LIMIT 1",
(ord_no, strategy_id, code),
).fetchone()
else:
row = self.conn.execute(
"SELECT * FROM orders WHERE ord_no=%s "
"ORDER BY submitted_at DESC LIMIT 1",
(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.ffffff', varchar(26))
"""
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(26) DEFAULT NULL"
)
logger.info("📌 target_candidates_history.event_time 컬럼 추가")
else:
try:
meta = self.conn.execute(
"SHOW COLUMNS FROM target_candidates_history LIKE %s",
("event_time",),
).fetchone()
typ = str((meta or {}).get("Type") or "").lower()
n = 0
if "varchar" in typ:
import re
m = re.search(r"varchar\((\d+)\)", typ)
n = int(m.group(1)) if m else 0
if n and n < 26:
self.conn.execute(
"ALTER TABLE target_candidates_history "
"MODIFY COLUMN event_time VARCHAR(26) DEFAULT NULL"
)
logger.info(
"📌 target_candidates_history.event_time 길이 %s→26",
n,
)
except Exception as e:
logger.debug("event_time 길이 ALTER: %s", e)
# 인덱스 — 백테스트 조회 속도 확보
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 행 = 그 시점의 유니버스 전체.
INSERT만 (같은 초 DELETE 바꿔치기 없음). 한 스냅샷 N행은 한 트랜잭션.
Args:
strategy_id : 'SCALP' | 'SHORT' | 'BREAKOUT' ...
event_time : 'YYYY-MM-DD HH:MM:SS.ffffff'. 백테스트 기준 시각.
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:
try:
dp = event_time.replace("-", "").replace(":", "").replace(" ", "")
slot_key = dp[:12] # YYYYMMDDHHMM
except Exception:
slot_key = event_time[:12]
rows = []
for it in items:
code = (it.get("code") or "").strip()
if not code:
continue
raw_name = (it.get("name") or code)[:100]
name = raw_name
if not name or name == code:
try:
from kis_trader.utils.stock_name import resolve_stock_display_name
name = resolve_stock_display_name(
self, code, fallback=code, cache_to_meta=True,
)[:100]
except Exception:
name = code
rows.append(
(slot_key, event_time, code, name, 0.0, 0.0,
"Q", "", "", strategy_id, event_time),
)
if not rows:
return 0
sql = """
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)
"""
try:
n = self.conn.executemany_tx(sql, rows)
return int(n if n and n > 0 else len(rows))
except Exception as e:
logger.warning("history 스냅샷 저장 실패: %s", e)
return 0
# 범용 별칭 — 유니버스 소스가 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,
)
# ------------------------------------------------------------------
# 백테스트 헬퍼: 특정 시점의 유니버스 복원
# ------------------------------------------------------------------
@staticmethod
def _universe_history_table(history_source: str = "kiwoom") -> str:
from kis_trader.backtest.universe_history_source import history_table_for_source
return history_table_for_source(history_source)
def get_universe_at(
self, *, strategy_id: str, at_time: str, history_source: str = "kiwoom",
) -> List[Dict]:
"""
``at_time`` ('YYYY-MM-DD HH:MM:SS' 또는 마이크로초) 시점에 봇이 보던 유니버스를 복원.
= 그 시각 이전의 가장 최근 event_time 스냅샷.
초 단위 조회는 그 초의 **마지막** 마이크로 스냅샷.
``history_source``: ``kiwoom``(target_candidates_history) | ``ls``(ls_candidates_history)
"""
self._ensure_history_columns()
table = self._universe_history_table(history_source)
from kis_trader.backtest.universe_timeline import event_time_query_upper
at_q = event_time_query_upper(at_time)
try:
row = self.conn.execute(
f"""
SELECT MAX(event_time) AS et
FROM {table}
WHERE strategy_id=%s AND event_time <= %s
""",
(strategy_id, at_q),
).fetchone()
et = (row or {}).get("et") if row else None
if not et:
return []
rows = self.conn.execute(
f"""
SELECT code, name FROM {table}
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 실패(src=%s): %s", history_source, e)
return []
def iter_universe_events(
self,
*,
strategy_id: str,
start_time: str,
end_time: str,
preserve_insert_order: bool = False,
history_source: str = "kiwoom",
) -> List[Dict]:
"""
``[start_time, end_time]`` 구간의 모든 스냅샷 이벤트를 시간순 반환.
각 이벤트 = {"event_time": ..., "codes": [...]}.
백테스트 루프에서 시점별 유니버스를 순회할 때 사용.
``preserve_insert_order=True``: 스냅샷 내 종목 순서를 DB insert(id) 순으로
유지 (백테 매수 우선순위 정합). 기본 False 는 code 가나다순.
``history_source``: ``kiwoom`` | ``ls``
"""
self._ensure_history_columns()
table = self._universe_history_table(history_source)
from kis_trader.backtest.universe_timeline import event_time_query_upper
end_q = event_time_query_upper(end_time)
order_clause = "event_time, id" if preserve_insert_order else "event_time, code"
try:
rows = self.conn.execute(
f"""
SELECT event_time, code, name
FROM {table}
WHERE strategy_id=%s
AND event_time BETWEEN %s AND %s
ORDER BY {order_clause}
""",
(strategy_id, start_time, end_q),
).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 실패(src=%s): %s", history_source, e)
return []
@staticmethod
def _add_minutes_to_candle_slot(candle_slot: str, minutes: int) -> str:
"""YYYYMMDDHHMM 슬롯에 분 단위 가산."""
from datetime import datetime, timedelta
dt = datetime.strptime(candle_slot, "%Y%m%d%H%M")
return (dt + timedelta(minutes=int(minutes))).strftime("%Y%m%d%H%M")
def get_universe_by_candle_time(
self,
*,
strategy_id: str,
start_ymd: str,
end_ymd: str,
strict: bool = False,
strict_lag_minutes: int = 1,
exit_debounce_sec: int = 0,
history_source: str = "kiwoom",
) -> Dict[str, List[str]]:
"""
백테스트 편의용 — 1분봉 캔들 시각(YYYYMMDDHHMM)을 키로 하는 유니버스 dict.
실매매는 조건검색 변동 tick 마다 마이크로초 ``event_time``
(YYYY-MM-DD HH:MM:SS.ffffff) 으로 ``target_candidates_history`` 에 적재한다.
반면 백테스트 엔진은 1분봉 단위로 돌아가므로, 각 캔들에 대해
**그 캔들의 종가 형성 시각 (HH:MM:59 초의 마지막 스냅샷) 이전의 가장 최근 스냅샷** 을
선택해 "그 시점에 봇이 보던 유니버스" 를 재현한다.
``strict=True`` (모멘텀 백테 실매 정합):
분 전체에 스냅샷을 미리 적용하는 lookahead 를 제거한다.
이전 스냅샷 대비 **신규 편입** 종목만 ``event_time`` 분 + lag 이후 분봉부터 포함.
(예: 09:42:25 편입 + lag=1 → 09:43 분봉부터, 09:42 분봉에서는 제외)
Args:
strategy_id: 'SCALP' | 'SHORT' | 'BREAKOUT' | 'MOMENTUM'
start_ymd: 시작일 YYYYMMDD
end_ymd: 종료일 YYYYMMDD
strict: 종목별 첫 event_time 기준 편입 지연 적용
strict_lag_minutes: 첫 편입 분 이후 추가 대기 분 (기본 1)
exit_debounce_sec: 짧은 EXIT→재편입 무시(초). 0=OFF.
history_source: ``kiwoom`` | ``ls``
Returns:
``{candle_time(YYYYMMDDHHMM): [code, ...]}`` —
해당 1분 캔들 종료 시점에 유효했던 유니버스 코드 리스트.
스냅샷이 하나도 없으면 빈 dict.
"""
self._ensure_history_columns()
from kis_trader.backtest.universe_history_source import (
apply_ls_session_filter_to_start,
normalize_universe_history_source,
)
history_source = normalize_universe_history_source(history_source)
# 조회 범위: 전일 마지막 스냅샷도 포함하기 위해 시작일 00:00:00 이전 1건은
# 엔진 쪽에서 "이전에 유효했던 유니버스" 로 물려받는 게 자연스럽다.
# 단순화를 위해 start_ymd 00:00:00 ~ end_ymd 23:59:59 범위로 쿼리.
# LS + SESSION_ONLY: 장전 sticky 스냅 제외 → 당일 09:00 부터.
start_time = (
f"{start_ymd[:4]}-{start_ymd[4:6]}-{start_ymd[6:8]} 00:00:00"
)
start_time = apply_ls_session_filter_to_start(
start_time, source=history_source,
)
end_time = (
f"{end_ymd[:4]}-{end_ymd[4:6]}-{end_ymd[6:8]} 23:59:59.999999"
)
events = self.iter_universe_events(
strategy_id=strategy_id,
start_time=start_time,
end_time=end_time,
preserve_insert_order=True,
history_source=history_source,
)
if not events:
return {}
from kis_trader.backtest.universe_timeline import _event_time_to_key
# 각 스냅샷의 event_time 을 'YYYYMMDDHHMMSSffffff' 로 정규화.
# event_time 문자열은 이미 시간순 정렬됨 (iter_universe_events).
normalized: List[tuple] = []
for ev in events:
et_key = _event_time_to_key(str(ev.get("event_time") or ""))
if not et_key:
continue
codes = [it["code"] for it in ev.get("items", []) if it.get("code")]
normalized.append((et_key, codes))
if not normalized:
return {}
if exit_debounce_sec > 0:
try:
from kis_trader.backtest.momentum_universe_timeline import (
debounce_universe_snapshots,
)
normalized = debounce_universe_snapshots(
normalized, int(exit_debounce_sec),
)
except Exception as e:
logger.warning("유니버스 EXIT 디바운스 실패(원본 사용): %s", e)
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]
strict_lag = max(0, int(strict_lag_minutes))
prev_codes_set: set = set()
code_avail: Dict[str, str] = {}
if strict:
try:
prev_items = self.get_universe_at(
strategy_id=strategy_id, at_time=start_time,
history_source=history_source,
)
prev_codes_set = {
str(it["code"]) for it in prev_items if it.get("code")
}
code_avail = {c: "000000000000" for c in prev_codes_set}
except Exception as e:
logger.warning("strict 유니버스 초기 스냅샷 실패: %s", e)
# 날짜별 장중 시간대 (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 + "59999999" # YYYYMMDDHHMMSS + 999999
# 그 이전 또는 같은 시각의 가장 최근 event_time 까지 포인터 전진
while ev_idx < n_ev and normalized[ev_idx][0] <= candle_end_key:
et_key, codes = normalized[ev_idx]
if strict:
cur_set = {str(c) for c in codes if c}
et_slot = et_key[:12]
avail_slot = (
self._add_minutes_to_candle_slot(et_slot, strict_lag)
if strict_lag > 0 else et_slot
)
for c in cur_set - prev_codes_set:
code_avail[c] = avail_slot
for c in prev_codes_set - cur_set:
code_avail.pop(c, None)
prev_codes_set = cur_set
active_codes = codes
ev_idx += 1
if active_codes and candle_end_key >= first_ev_key:
if strict:
filtered = [
c for c in active_codes
if candle_time >= code_avail.get(str(c), "000000000000")
]
if filtered:
out[candle_time] = filtered
else:
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)