브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
313
kis_trader/network/ranking_manager.py
Normal file
313
kis_trader/network/ranking_manager.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user