""" 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._current_order: Dict[str, List[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_order.get(sid) or []) if not codes: 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, "updow_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: from ..utils.universe_source import universe_source_active # (blng, market, limit) 조합이 같으면 REST 1회만 호출 cache: Dict[Tuple[str, str, int, bool], List[Dict]] = {} for cfg in self._configs: sid = cfg["strategy_id"] if not universe_source_active(sid, "ranking"): continue 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 ordered: List[str] = [] seen: Set[str] = set() for r in rows: code = ( r.get("mksc_shrn_iscd") or r.get("stk_cd") or r.get("code") or "" ).strip() if code and code in new_set and code not in seen: seen.add(code) ordered.append(code) for c in new_set: if c not in seen: ordered.append(c) self._current_order[strategy_id] = ordered 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: if not self._history_save_allowed(strategy_id): return with self._lock: ordered = list(self._current_order.get(strategy_id) or []) self._save_snapshot(strategy_id, ordered, new_names) @staticmethod def _history_save_allowed(strategy_id: str) -> bool: """active UNIVERSE_SOURCE 가 ``ranking`` 일 때만 ranking history 기록.""" from ..utils.universe_source import universe_source_active sid = (strategy_id or "").upper() if not sid: return True try: return universe_source_active(sid, "ranking") except Exception: return True def _save_snapshot( self, strategy_id: str, codes_ordered: List[str], names: Dict[str, str], ) -> None: """변동 감지 tick 마다 즉시 DB 에 풀 스냅샷 INSERT (랭킹 응답 순서 유지).""" if not (self.history_enabled and self.db is not None): return event_time = dt.now().strftime("%Y-%m-%d %H:%M:%S.%f") items = [ {"code": c, "name": names.get(c, c)} for c in codes_ordered if c ] 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)