refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.

This commit is contained in:
Your Name
2026-08-12 10:19:19 +09:00
parent cb7e5037a0
commit c6bd62a25f
218 changed files with 31613 additions and 759 deletions

View File

@@ -349,7 +349,9 @@ class KISWebSocketPriceCache:
return
self._subscribed.add(code)
if self._connected and self._ws:
self._send_sub_msg(code, subscribe=True)
self._send_sub_msg(code, subscribe=True, tr_id="H0STCNT0")
if get_env_bool("WS_ORDERBOOK_SAVE_KIS", False):
self._send_sub_msg(code, subscribe=True, tr_id="H0STASP0")
logger.info("📡 WebSocket 구독 추가: %s (%d/%d)", code, len(self._subscribed), self.MAX_SUBSCRIPTIONS)
def unsubscribe(self, code: str) -> None:
@@ -366,7 +368,9 @@ class KISWebSocketPriceCache:
with self._cache_lock:
self._cache.pop(code, None)
if self._connected and self._ws:
self._send_sub_msg(code, subscribe=False)
self._send_sub_msg(code, subscribe=False, tr_id="H0STCNT0")
if get_env_bool("WS_ORDERBOOK_SAVE_KIS", False):
self._send_sub_msg(code, subscribe=False, tr_id="H0STASP0")
logger.info("📡 WebSocket 구독 해제: %s", code)
def add_price_listener(self, callback) -> None:
@@ -425,6 +429,17 @@ class KISWebSocketPriceCache:
return None
return entry.get("data")
def get_orderbook(self, code: str, max_age_sec: float = 3.0) -> Optional[dict]:
"""메모리에 저장된 최신 KIS 호가를 반환합니다."""
with self._cache_lock:
entry = self._ob_cache.get(code)
if not entry:
return None
age = time.time() - entry.get("ts", 0)
if age > max_age_sec:
return None
return entry.get("data")
@property
def is_active(self) -> bool:
"""WebSocket이 연결되어 실시간 데이터를 수신 중이면 True."""
@@ -607,7 +622,7 @@ class KISWebSocketPriceCache:
except Exception:
pass
def _build_sub_payload(self, code: str, subscribe: bool) -> str:
def _build_sub_payload(self, code: str, subscribe: bool, tr_id: str = "H0STCNT0") -> str:
"""구독(tr_type=1) / 해제(tr_type=2) JSON 메시지 생성."""
return json.dumps({
"header": {
@@ -618,18 +633,18 @@ class KISWebSocketPriceCache:
},
"body": {
"input": {
"tr_id": "H0STCNT0",
"tr_id": tr_id,
"tr_key": code,
}
},
})
def _send_sub_msg(self, code: str, subscribe: bool = True) -> None:
def _send_sub_msg(self, code: str, subscribe: bool = True, tr_id: str = "H0STCNT0") -> None:
"""WebSocket으로 구독/해제 메시지 전송. 실패 시 조용히 무시."""
if not self._ws:
return
try:
self._ws.send(self._build_sub_payload(code, subscribe))
self._ws.send(self._build_sub_payload(code, subscribe, tr_id))
except Exception as e:
logger.debug("구독 메시지 전송 실패(%s): %s", code, e)
@@ -694,9 +709,38 @@ class KISWebSocketPriceCache:
if len(parts) < 4:
return
# parts[0]=암호화구분, parts[1]=TR_ID, parts[2]=건수, parts[3]=데이터
if parts[1] != "H0STCNT0":
if parts[1] == "H0STASP0":
# 실시간 호가 (H0STASP0)
try:
raw_data = parts[3]
fields = raw_data.split('^')
if len(fields) >= 42:
code_val = fields[0]
snap = {
"BSOP_HOUR": fields[1],
"ASKP1": fields[3], "BIDP1": fields[13],
"TOTAL_ASKP_RSQN": fields[43],
"TOTAL_BIDP_RSQN": fields[44],
}
levels = []
for i in range(10):
ask = float(fields[3 + i] or 0)
bid = float(fields[13 + i] or 0)
ask_vol = int(fields[23 + i] or 0)
bid_vol = int(fields[33 + i] or 0)
if ask > 0 or bid > 0:
levels.append({"a": ask, "av": ask_vol, "b": bid, "bv": bid_vol})
snap["levels"] = levels
if hasattr(self.db, "insert_kis_ws_orderbook"):
self.db.insert_kis_ws_orderbook(code=code_val, snap=snap, market="KR")
except Exception as e:
logger.debug("H0STASP0 호가 파싱 오류: %s", e)
return
if parts[1] != "H0STCNT0":
return
# 암호화된 데이터는 아직 미지원 (평문만 처리)
if parts[0] == "1":
logger.debug("H0STCNT0 암호화 데이터 수신 (처리 스킵) → REST fallback 권장")
@@ -1297,7 +1341,7 @@ class CandleAggregator:
return bool(getattr(self, "_freeze_on_confirm_cached", True))
def _load_confirmed_ohlcv_from_db(
self, code: str, tf: int, candle_times: list,
self, code: str, tf: int, candle_times: list, source: str = "kis"
) -> Dict[str, Dict]:
"""
freeze 재시작 정합: RAM 이 비어도 DB 에 이미 확정된 봉은 REST 로 덮지 않고
@@ -1320,10 +1364,10 @@ class CandleAggregator:
SELECT candle_time, `open`, high, low, close, volume,
rsi_2, rsi_3, rsi_5, source, holding_peak
FROM ws_candles
WHERE code=%s AND timeframe=%s AND is_confirmed=1
WHERE code=%s AND timeframe=%s AND source=%s AND is_confirmed=1
AND candle_time IN ({ph})
""",
(code, int(tf), *chunk),
(code, int(tf), source, *chunk),
).fetchall()
for r in rows or []:
ct = str(r.get("candle_time") or "")[:12]
@@ -1520,6 +1564,7 @@ class CandleAggregator:
*,
market: str = "KR",
timeframes: Optional[list] = None,
source: str = "kis",
) -> None:
"""
KISWebSocketPriceCache._parse_realtime_msg 에서 틱마다 호출됨.
@@ -1533,17 +1578,17 @@ class CandleAggregator:
tfs = list(timeframes) if timeframes is not None else self.timeframes
with self._lock:
for tf in tfs:
self._process_tick(code, price, volume, tick_time, tf, market=mk)
self._process_tick(code, price, volume, tick_time, tf, market=mk, source=source)
def _process_tick(self, code: str, price: float, volume: int,
tick_time: str, tf: int, *, market: str = "KR") -> None:
tick_time: str, tf: int, *, market: str = "KR", source: str = "kis") -> None:
"""
단일 timeframe 에 대한 틱 처리 (lock 내부에서 호출).
[트랙 1] RAM 갱신만 수행, DB 호출 없음 → 블로킹 0ms
[트랙 2] 봉 확정 순간에만 Queue.put_nowait() → 기록원이 비동기 배치 저장
"""
key = (code, tf)
key = (code, tf, source)
new_ctime = self._candle_key(tick_time, tf)
if key not in self._current:
@@ -1556,6 +1601,7 @@ class CandleAggregator:
"volume": int(volume) if inc else 0,
"_acml_base": None if inc else int(volume),
"market": market,
"source": source,
}
return
@@ -1580,6 +1626,7 @@ class CandleAggregator:
"volume": int(volume) if inc else 0,
"_acml_base": None if inc else int(volume),
"market": market,
"source": source,
}
else:
# 같은 봉: OHLCV 갱신 (RAM만, DB 쓰기 없음)
@@ -1607,7 +1654,9 @@ class CandleAggregator:
- ``WS_CANDLE_FREEZE_ON_CONFIRM``(기본 true): OHLCV 유지(첫 확정 승), holding_peak 만 갱신
- freeze OFF: volume 더 큰 쪽 upsert (레거시)
"""
code, tf = key
code = key[0]
tf = key[1]
source = key[2] if len(key) >= 3 else "kis"
ctime = str(cur.get("candle_time") or "")[:12]
buf = self._confirmed.setdefault(key, [])
closes = self._closes.setdefault(key, [])
@@ -1692,7 +1741,7 @@ class CandleAggregator:
"rsi_3": rsi3,
"rsi_5": rsi5,
"is_confirmed": 1,
"source": "ws",
"source": cur.get("source", "ws"),
}
if hp is not None:
confirmed_candle["holding_peak"] = hp
@@ -1741,7 +1790,8 @@ class CandleAggregator:
cur = self._current.get(key)
if not cur:
continue
code, tf = key
code = key[0]
tf = key[1]
try:
bucket_start = _dt.datetime.strptime(cur["candle_time"], "%Y%m%d%H%M")
except Exception:
@@ -1846,6 +1896,10 @@ class CandleAggregator:
self._open_bucket_ctime(tf, now=now) if skip_incomplete_bucket else ""
)
first_src = "kis"
if bars:
first_src = str(bars[0].get("source") or "kis")
# lock 밖에서 DB 조회 (재시작 후 RAM 공백 → REST 가 DB 확정봉을 덮는 것 방지)
db_frozen: Dict[str, Dict] = {}
if freeze and bars and self.db is not None:
@@ -1854,10 +1908,10 @@ class CandleAggregator:
ct = str(row.get("candle_time") or row.get("time") or "")[:12]
if ct and len(ct) >= 12:
want_times.append(ct)
db_frozen = self._load_confirmed_ohlcv_from_db(code, tf, want_times)
db_frozen = self._load_confirmed_ohlcv_from_db(code, tf, want_times, source=first_src)
with self._lock:
key = (code, tf)
key = (code, tf, first_src)
closes = self._closes.setdefault(key, [])
conf_buf = self._confirmed.setdefault(key, [])
@@ -2064,7 +2118,7 @@ class CandleAggregator:
if tf <= 1:
return 0
with self._lock:
bars_1m = list(self._confirmed.get((code, 1), []))
bars_1m = list(self._confirmed.get((code, 1, "kis"), []))
if not bars_1m:
return 0
rolled = rollup_1m_bars_to_tf(bars_1m, tf)
@@ -2076,46 +2130,46 @@ class CandleAggregator:
# [트랙 1] RAM 버퍼 조회 — 매수/매도 루프에서 직접 호출 (DB 조회 없음)
# ------------------------------------------------------------------
def get_latest_confirmed(self, code: str, tf: int) -> Optional[dict]:
def get_latest_confirmed(self, code: str, tf: int, source: str = "kis") -> Optional[dict]:
"""
가장 최근 확정된 봉(완성된 마지막 봉)을 반환.
None이면 아직 봉이 확정되지 않음 (장 초반 등).
"""
with self._lock:
buf = self._confirmed.get((code, tf))
buf = self._confirmed.get((code, tf, source))
return buf[-1] if buf else None
def get_prev_confirmed(self, code: str, tf: int) -> Optional[dict]:
def get_prev_confirmed(self, code: str, tf: int, source: str = "kis") -> Optional[dict]:
"""직전 확정봉 (최신에서 2번째). 패턴 확인용 (현재봉 - 1)."""
with self._lock:
buf = self._confirmed.get((code, tf))
buf = self._confirmed.get((code, tf, source))
return buf[-2] if buf and len(buf) >= 2 else None
def get_candles(self, code: str, tf: int, n: int = 10) -> list:
def get_candles(self, code: str, tf: int, n: int = 10, source: str = "kis") -> list:
"""최근 n개 확정 봉 리스트 반환 (오래된→최신 순)."""
with self._lock:
buf = self._confirmed.get((code, tf), [])
buf = self._confirmed.get((code, tf, source), [])
return list(buf[-n:])
def get_confirmed_count(self, code: str, tf: int) -> int:
def get_confirmed_count(self, code: str, tf: int, source: str = "kis") -> int:
"""확정된 봉 수 (RSI 안정화 여부 확인용)."""
with self._lock:
return len(self._confirmed.get((code, tf), []))
return len(self._confirmed.get((code, tf, source), []))
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
def get_current_candle(self, code: str, tf: int, source: str = "kis") -> Optional[dict]:
"""
현재 진행 중인 봉(미확정, is_confirmed=0) 반환.
RSI는 포함되지 않음 (확정 봉 기준으로만 계산).
"""
with self._lock:
return dict(self._current.get((code, tf), {})) or None
return dict(self._current.get((code, tf, source), {})) or None
def get_rsi(self, code: str, tf: int, period: int = 3) -> Optional[float]:
def get_rsi(self, code: str, tf: int, period: int = 3, source: str = "kis") -> Optional[float]:
"""
최신 확정 봉의 RSI(period) 값 반환.
period: 2, 3, 5 중 하나 (스캘핑 단타용 초단기 RSI)
"""
candle = self.get_latest_confirmed(code, tf)
candle = self.get_latest_confirmed(code, tf, source=source)
if candle is None:
return None
return candle.get(f"rsi_{period}")
@@ -2128,10 +2182,11 @@ class CandleAggregator:
"""
with self._lock:
for tf in list(self.timeframes):
key = (code, tf)
self._confirmed.pop(key, None)
self._closes.pop(key, None)
self._current.pop(key, None)
for src in ("kis", "kiwoom", "ls", "ws", "kw_rest"):
key = (code, tf, src)
self._confirmed.pop(key, None)
self._closes.pop(key, None)
self._current.pop(key, None)
logger.debug("🗑️ CandleAggregator RAM 정리: %s", code)