변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
351 lines
13 KiB
Python
351 lines
13 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._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")
|
|
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)
|