refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user