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

314 lines
12 KiB
Python

"""
kis_trader/network/ranking_manager.py — 거래량/체결강도 순위 기반 유니버스
============================================================================
전략별 유니버스를 KIS 랭킹 REST(volume-rank FHPST01710000) 로 갱신하는 매니저.
왜 조건검색 대신 랭킹인가:
* 조건검색은 "구조적 필터" (20일 신고가·이평정배열 등 돌파용) 에 강하지만
"지금 반등 중" 같은 동적 판단엔 약하다.
* 실제 매매 시점 판정은 전략 코드에서 정밀 계산 (체결강도·분봉 반등 등) 하면
충분. 유니버스 단계에선 **활발한 종목 풀** 만 뽑으면 됨.
* 거래량/체결강도 상위는 KIS REST 한 방에 100~200건을 받아올 수 있고
재현 가능(tick 단위 저장 가능) → 백테스트 친화.
동작:
1. configs 리스트로 전략별 랭킹 소스 지정.
configs = [
{"strategy_id": "SCALP", "sort": "volume", "limit": 100, "market": "J"},
{"strategy_id": "SHORT", "sort": "volume", "limit": 100, "market": "J"},
# sort 후보: volume | trading_value | strength | fluct_up | fluct_down
]
2. 동일 (sort, market, limit) 조합은 REST 1회만 호출 (캐시).
3. 변동 감지 tick 마다 target_candidates_history 에 초단위 ``event_time`` 으로
**즉시 INSERT** (ConditionSearchManager 와 동일 패턴).
— 버퍼/배치 flush 는 단일 행 INSERT 대비 실익이 없고 crash 내구성만 낮추므로
제거. INSERT 1회 ≈ 1ms 미만이라 10초 폴링 루프에 영향 없음.
ConditionSearchManager 와의 공용 인터페이스 (전략 쪽에서는 구분 불필요):
- start() / stop()
- get_universe_for(strategy_id) -> Set[str]
- get_candidates_for(strategy_id) -> List[Dict]
- get_universe() -> Set[str] (전체 합집합)
- _configs 속성 (heartbeat 용)
"""
from __future__ import annotations
import random
import threading
import time
from datetime import datetime as dt
from typing import Callable, Dict, List, Optional, Set, Tuple
from ..utils.env import get_env_bool, get_env_int
from ..utils.logger import get_logger
logger = get_logger("kis_trader.rank")
_SORT_MAP = {
# 공개 별칭 → KIS FID_BLNG_CLS_CODE 매핑
"volume": "0",
"vol": "0",
"trading_value": "3",
"value": "3",
"strength": "6",
"cntr_str": "6",
"fluct_up": "4",
"up": "4",
"fluct_down": "5",
"down": "5",
"decline": "5",
}
class VolumeRankManager:
"""거래량/체결강도 순위 기반 동적 유니버스 매니저.
저장 정책: 변동 감지 tick 마다 초단위 ``event_time`` 으로 DB 에 **즉시 INSERT**.
(과거엔 큐 + 기록원 스레드로 배치 flush 했으나, 단일 행 INSERT 대비 실익이
없고 crash 시 미-flush tick 유실 위험만 낮추므로 제거했다.)
"""
def __init__(
self,
*,
client,
configs: List[Dict],
on_change: Optional[Callable[[str, Set[str], Set[str], Set[str]], None]] = None,
poll_interval_sec: Optional[float] = None,
db=None,
):
self.client = client
self.on_change = on_change
self.db = db
self.poll_interval = float(
poll_interval_sec
if poll_interval_sec is not None
else get_env_int("RANKING_POLL_INTERVAL_SEC", 10)
)
# 히스토리 저장 플래그 (ConditionSearchManager 와 env 공유 의도로 이름은 UNIVERSE_HISTORY_SAVE)
self.history_enabled = get_env_bool("UNIVERSE_HISTORY_SAVE", True)
# 설정 정규화
self._configs: List[Dict] = []
for c in configs or []:
sid = str(c.get("strategy_id") or "").strip().upper()
if not sid:
continue
sort_key = str(c.get("sort") or "volume").strip().lower()
blng = _SORT_MAP.get(sort_key)
if not blng:
logger.warning(
"⚠️ 알 수 없는 sort=%s (strategy=%s) → 'volume' 사용",
sort_key, sid,
)
sort_key = "volume"
blng = "0"
self._configs.append({
"strategy_id": sid,
"sort": sort_key,
"blng": blng,
"market": str(c.get("market") or "J").strip() or "J",
"limit": int(c.get("limit") or 100),
"exclude_non_stock": bool(c.get("exclude_non_stock", True)),
})
self._thread: Optional[threading.Thread] = None
self._running = False
self._current: Dict[str, Set[str]] = {}
self._name_map: Dict[str, str] = {}
self._initialized: Set[str] = set()
self._lock = threading.Lock()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self) -> bool:
if not self._configs:
logger.info("랭킹 매니저 configs 없음 → 비활성")
return False
for cfg in self._configs:
logger.info(
"🔗 랭킹 매핑: strategy=%s sort=%s market=%s limit=%d",
cfg["strategy_id"], cfg["sort"], cfg["market"], cfg["limit"],
)
self._running = True
self._thread = threading.Thread(
target=self._loop, daemon=True, name="VolumeRank",
)
self._thread.start()
logger.info(
"✅ 랭킹 폴링 시작 (%d개, interval=%ds, history=%s)",
len(self._configs), int(self.poll_interval),
"ON" if (self.history_enabled and self.db is not None) else "OFF",
)
return True
def stop(self) -> None:
"""폴링 스레드 정리."""
self._running = False
def get_universe_for(self, strategy_id: str) -> Set[str]:
sid = (strategy_id or "").upper()
with self._lock:
return set(self._current.get(sid, set()))
def get_candidates_for(self, strategy_id: str) -> List[Dict]:
"""BaseStrategy._load_candidates 와 호환되는 dict 리스트."""
sid = (strategy_id or "").upper()
with self._lock:
codes = list(self._current.get(sid, set()))
nm = dict(self._name_map)
out = []
for c in codes:
out.append({
"code": c,
"name": nm.get(c, c),
"scalp_on": True,
"tail_on": True,
"score": 0.0,
"price": 0.0,
})
return out
def get_universe(self) -> Set[str]:
"""전체 합집합 (heartbeat/총량 로그용)."""
with self._lock:
out: Set[str] = set()
for s in self._current.values():
out |= s
return out
def get_candidates(self) -> List[Dict]:
if not self._configs:
return []
return self.get_candidates_for(self._configs[0]["strategy_id"])
# ------------------------------------------------------------------
# 내부
# ------------------------------------------------------------------
def _loop(self) -> None:
while self._running:
try:
self._tick_all()
except Exception as e:
logger.error("랭킹 루프 예외: %s", e)
jitter = min(1.5, self.poll_interval * 0.1)
sleep_sec = self.poll_interval + random.uniform(0, jitter)
deadline = time.time() + sleep_sec
while time.time() < deadline:
if not self._running:
return
time.sleep(0.5)
def _tick_all(self) -> None:
# (blng, market, limit) 조합이 같으면 REST 1회만 호출
cache: Dict[Tuple[str, str, int, bool], List[Dict]] = {}
for cfg in self._configs:
key = (cfg["blng"], cfg["market"], cfg["limit"], cfg["exclude_non_stock"])
if key in cache:
rows = cache[key]
else:
try:
rows = self.client._fetch_volume_rank(
market=cfg["market"],
blng_cls_code=cfg["blng"],
limit=cfg["limit"],
exclude_non_stock=cfg["exclude_non_stock"],
) or []
except Exception as e:
logger.debug(
"랭킹 조회 실패 (blng=%s market=%s): %s",
cfg["blng"], cfg["market"], e,
)
rows = []
cache[key] = rows
self._apply_result(cfg["strategy_id"], rows)
def _apply_result(self, strategy_id: str, rows: List[Dict]) -> None:
new_set: Set[str] = set()
new_names: Dict[str, str] = {}
for r in rows:
code = (
r.get("mksc_shrn_iscd") or r.get("stk_cd")
or r.get("code") or ""
).strip()
if not code or len(code) != 6:
continue
name = (
r.get("hts_kor_isnm") or r.get("stk_nm")
or r.get("prst_name") or code
).strip() or code
new_set.add(code)
new_names[code] = name
with self._lock:
prev = self._current.get(strategy_id, set())
first_tick = strategy_id not in self._initialized
enters = new_set - prev
exits = prev - new_set
self._current[strategy_id] = new_set
self._initialized.add(strategy_id)
for c, n in new_names.items():
self._name_map[c] = n
changed = bool(enters or exits)
if changed:
logger.info(
"🔄 [%s] +%d / -%d (현재 %d종목, rank)",
strategy_id, len(enters), len(exits), len(new_set),
)
if enters:
preview = ", ".join(
f"{c}({new_names.get(c, c)})" for c in list(sorted(enters))[:5]
)
logger.info(" ENTER: %s%s",
preview, "" if len(enters) > 5 else "")
if exits:
preview = ", ".join(sorted(exits)[:5])
logger.info(" EXIT : %s%s",
preview, "" if len(exits) > 5 else "")
if self.on_change and changed:
try:
self.on_change(strategy_id, new_set, enters, exits)
except Exception as e:
logger.warning("on_change 콜백 예외: %s", e)
# 변동 또는 첫 tick → 풀 스냅샷 저장 (condition 과 동일 테이블/포맷)
if changed or first_tick:
self._save_snapshot(strategy_id, new_set, new_names)
def _save_snapshot(
self, strategy_id: str, codes: Set[str], names: Dict[str, str],
) -> None:
"""변동 감지 tick 마다 즉시 DB 에 풀 스냅샷 INSERT.
* ``event_time`` 은 REST 응답 직후 찍은 초단위 시각 (YYYY-MM-DD HH:MM:SS).
* 이 값이 백테스트 매칭 기준 시각이며, DB INSERT 가 1ms 지연되든 100ms
지연되든 행에 박히는 event_time 은 불변이므로 정합성은 보존.
* 폴링은 10초 주기라 DB 부하 매우 적음. 버퍼링으로 얻을 이득 없음.
"""
if not (self.history_enabled and self.db is not None):
return
event_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
items = [{"code": c, "name": names.get(c, c)} for c in sorted(codes)]
try:
n = self.db.insert_condition_universe_snapshot(
strategy_id=strategy_id,
event_time=event_time,
items=items,
)
logger.debug(
"📼 [history/rank] %s @%s %d종목 INSERT",
strategy_id, event_time, n,
)
except Exception as e:
# INSERT 실패는 매매 흐름에 영향 없어야 함 → 경고만 남기고 계속.
logger.warning("유니버스 히스토리 INSERT 실패 (%s): %s", strategy_id, e)