Files
kis_bot/kis_trader/network/ranking_manager.py
Your Name 36a3e2b4a1 feat: Enhance trading system with new permanent subscription features and order book management
Changes:
- Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically.
- Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database.
- Introduced a mechanism to handle master subscription states, improving the management of subscription statuses.
- Updated the database schema to include new fields for managing subscription states and order book filtering.

Impact:
- These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies.

히스토리 align 제거 븅신같은 초기설계 아예 제거
진입모드에 구멍메움
호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
2026-08-15 23:01:14 +09:00

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.%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)