이번에 들어간 내용
한투 호가 = 2번째 앱키 전용 키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger. LS RAM 합집합 후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로. 분봉 쓰레기 → 다음 소스 봉 통째 그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true. 파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개. 같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
"""
|
||||
kis_trader/ws/kiwoom_ws.py — 키움 WebSocket 실시간 시세 캐시 (시세 마이그레이션 검증용)
|
||||
kis_trader/ws/kiwoom_ws.py — 키움 WebSocket 실시간 시세 캐시
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
목적
|
||||
----
|
||||
KIS WS(41 한도)의 시세를 키움 WS(100 한도)로 옮기기 전에, 둘을 동시에 돌려
|
||||
가격 일치성을 검증하기 위한 키움 WebSocket 클라이언트.
|
||||
실매 키움 실시간 시세(체결 0B · 호가 0D · 프로그램 0w) **한 소켓**.
|
||||
예전에는 KIS 41 한도 대비 검증용이었으나, 지금은 틱/호가 본체.
|
||||
|
||||
``LIVE_TICK_PROVIDER`` 는 이 모듈이 아니라 ``WSManager.get_price`` 읽기 순서.
|
||||
``WS_SUBSCRIBE_KIS_MINIMAL`` 은 한투 구독 명단. 키움 후보 구독과 무관.
|
||||
|
||||
설계 원칙 (KIS WS 와 동일)
|
||||
--------------------------
|
||||
- ``Dict[str, Dict]`` 메모리 캐시 (락만 잠그고 마이크로초 read/write)
|
||||
- ``get_price(code)`` 인터페이스를 KIS WS 와 100% 동일 포맷으로 제공
|
||||
→ 봇 코드 재사용성 100%
|
||||
- ``KiwoomTokenManager`` 싱글톤 (kis_ws.py 안에 있음) 재사용
|
||||
- 재연결 백오프, approval 갱신, 종목 등록/해지
|
||||
- 재연결 백오프, 종목 등록/해지
|
||||
|
||||
키움 WebSocket 스펙
|
||||
-------------------
|
||||
@@ -34,18 +36,16 @@ URL 모의: wss://mockapi.kiwoom.com:10000/api/dostk/websocket
|
||||
14 = 누적거래대금 15 = 거래량(체결량)
|
||||
16 = 시가 17 = 고가 18 = 저가 20 = 체결시간(HHMMSS)
|
||||
|
||||
(키움 OpenAPI+ FID 정의 — 종목별 동일)
|
||||
|
||||
설치
|
||||
----
|
||||
``pip install websocket-client`` (KIS WS 와 공용)
|
||||
|
||||
토글 (DB env_config)
|
||||
--------------------
|
||||
``WS_PROVIDER`` (기본 ``kis_only`` — 키움 WS 미기동)
|
||||
키움 WS 기동: Orchestrator ``_start_ws_validator`` — 키 있으면 기동.
|
||||
``WS_PROVIDER=kis_only`` 로 끄지 않음(구식 주석).
|
||||
``KIWOOM_WS_URL_REAL`` / ``KIWOOM_WS_URL_MOCK`` (URL 재정의용)
|
||||
``KIWOOM_WS_REG_CHUNK_SIZE`` / ``KIWOOM_WS_REG_GAP_SEC`` / ``KIWOOM_WS_REG_DEBOUNCE_SEC``
|
||||
— REG(TRNM) 초당 허용 건수 초과 방지(배치·전송 간격·단건 디바운스).
|
||||
``KIWOOM_WS_REG_REFRESH`` (기본 1) — REG ``refresh``. 0=기존 그룹 구독 해지 후 재등록.
|
||||
LOGIN 직후 일괄 REG **첫 청크만** 0. 장중 종목 추가 REG는 항상 1
|
||||
(청크마다 0이면 앞 종목이 지워짐). 장중 상시 0 비추(catch-up 재점화).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -119,7 +119,9 @@ class KiwoomWebSocketPriceCache:
|
||||
FID_LOW = "18"
|
||||
FID_TICK_VOL = "15"
|
||||
FID_TICK_TIME = "20"
|
||||
FID_EXEC_STRENGTH = "567" # 체결강도 (%)
|
||||
FID_EXEC_STRENGTH = "228" # 체결강도 (%) — 키움 0B 공식
|
||||
FID_UPPER_LIMIT_TIME = "567" # 상한가발생시간 HHmmss
|
||||
FID_LOWER_LIMIT_TIME = "568" # 하한가발생시간 HHmmss
|
||||
|
||||
# 한도 (키움 권장)
|
||||
MAX_SUBSCRIPTIONS_PER_GROUP = 100 # grp_no=1 그룹 1개당
|
||||
@@ -160,6 +162,11 @@ class KiwoomWebSocketPriceCache:
|
||||
# 메모리 캐시 — KIS WS 와 동일 포맷 (data + ts)
|
||||
self._cache: Dict[str, Dict] = {}
|
||||
self._cache_lock = threading.Lock()
|
||||
# 종목별 마지막 FID20 — 재연결 catch-up(시간 후퇴/지연) vs 시계 동결 구분
|
||||
self._fid20_last: Dict[str, Any] = {}
|
||||
# 현재 FID20 를 처음 본 벽시계 — 같은 초 연속 체결 vs 아침 동결(수 분) 구분
|
||||
self._fid20_first_wall: Dict[str, float] = {}
|
||||
self._stale_skip_log_ts: Dict[str, float] = {}
|
||||
self._price_listeners: list = []
|
||||
self._price_listener_lock = threading.Lock()
|
||||
self._orderbook_cache = OrderbookCache() if OrderbookCache else None
|
||||
@@ -234,6 +241,36 @@ class KiwoomWebSocketPriceCache:
|
||||
self.is_mock, self._ws_url)
|
||||
return True
|
||||
|
||||
def _unsubscribe_all_before_close(self, reason: str = "", *, clear_ram: bool = True) -> int:
|
||||
"""세션 close 전 서버 REMOVE. LOGIN 전(구독 없음)이면 0.
|
||||
|
||||
RAM 은 ``clear_ram=True`` (stop) 일 때만 비운다.
|
||||
"""
|
||||
with self._sub_lock:
|
||||
codes = sorted(self._subscribed)
|
||||
if clear_ram:
|
||||
self._reg_batch_codes.clear()
|
||||
self._subscribed.clear()
|
||||
if not codes or not self._connected or not self._authenticated or self._ws is None:
|
||||
return 0
|
||||
n_ok = 0
|
||||
try:
|
||||
chunk = self._reg_chunk_size()
|
||||
gap = self._reg_gap_sec()
|
||||
for i in range(0, len(codes), chunk):
|
||||
part = codes[i:i + chunk]
|
||||
if self._send_remove(part):
|
||||
n_ok += len(part)
|
||||
if i + chunk < len(codes):
|
||||
time.sleep(gap)
|
||||
logger.info(
|
||||
"✅ 키움 WS 종료 전 REMOVE %d/%d종목 (%s)",
|
||||
n_ok, len(codes), reason or "-",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS 종료 전 REMOVE 실패: %s", e)
|
||||
return n_ok
|
||||
|
||||
def stop(self) -> None:
|
||||
"""수신 스레드 종료 + 구독 REMOVE 후 소켓 닫기 (재시작 세션 꼬임 완화)."""
|
||||
with self._reg_timer_lock:
|
||||
@@ -243,28 +280,10 @@ class KiwoomWebSocketPriceCache:
|
||||
except Exception:
|
||||
pass
|
||||
self._reg_timer = None
|
||||
with self._sub_lock:
|
||||
self._reg_batch_codes.clear()
|
||||
codes = sorted(self._subscribed)
|
||||
self._subscribed.clear()
|
||||
# 서버에 등록된 종목 REMOVE — KIS stop(clear_subscriptions=True) 와 동일 목적
|
||||
if codes and self._connected and self._authenticated and self._ws is not None:
|
||||
try:
|
||||
chunk = self._reg_chunk_size()
|
||||
gap = self._reg_gap_sec()
|
||||
n_ok = 0
|
||||
for i in range(0, len(codes), chunk):
|
||||
part = codes[i:i + chunk]
|
||||
if self._send_remove(part):
|
||||
n_ok += len(part)
|
||||
if i + chunk < len(codes):
|
||||
time.sleep(gap)
|
||||
logger.info(
|
||||
"✅ 키움 WS 종료 전 REMOVE %d/%d종목",
|
||||
n_ok, len(codes),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS 종료 전 REMOVE 실패: %s", e)
|
||||
try:
|
||||
self._unsubscribe_all_before_close(reason="stop", clear_ram=True)
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS 종료 전 REMOVE 실패: %s", e)
|
||||
self._running = False
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
@@ -519,8 +538,8 @@ class KiwoomWebSocketPriceCache:
|
||||
except Exception as ex:
|
||||
logger.debug("price_listener 예외 %s: %s", code, ex)
|
||||
|
||||
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[Dict]:
|
||||
"""KIS WS ``get_price`` 와 동일 포맷 반환.
|
||||
def get_price(self, code: str, max_age_sec: Optional[float] = None) -> Optional[Dict]:
|
||||
"""KIS WS ``get_price`` 와 동일 포맷 반환. None = 마지막 RAM (체결 공백이어도 유지).
|
||||
|
||||
반환::
|
||||
|
||||
@@ -541,7 +560,7 @@ class KiwoomWebSocketPriceCache:
|
||||
if not entry:
|
||||
return None
|
||||
age_sec = time.time() - entry.get("ts", 0)
|
||||
if age_sec > max_age_sec:
|
||||
if max_age_sec is not None and age_sec > max_age_sec:
|
||||
return None
|
||||
data = dict(entry["data"])
|
||||
data["_age_ms"] = int(age_sec * 1000)
|
||||
@@ -679,7 +698,9 @@ class KiwoomWebSocketPriceCache:
|
||||
pending = sorted(self._subscribed)
|
||||
self._reg_batch_codes.clear()
|
||||
if pending:
|
||||
self._send_reg_chunked(pending)
|
||||
# LOGIN ack 를 받은 같은 on_message 스레드에서 REG 청크 전송.
|
||||
# 이 동안 0B 파싱이 밀림. 장중 추가는 Timer 디바운스(별 스레드).
|
||||
self._send_reg_chunked(pending, group_replace=True)
|
||||
# 조건검색 등 공유 세션 모듈 — LOGIN 직후 CNSRLST 재등록
|
||||
self._fire_login_callbacks(ws)
|
||||
else:
|
||||
@@ -766,12 +787,14 @@ class KiwoomWebSocketPriceCache:
|
||||
except (ValueError, TypeError):
|
||||
return "0"
|
||||
|
||||
# KIS inquire_price 호환 필드 (스칼라 dict)
|
||||
cntr_raw = values.get(self.FID_EXEC_STRENGTH, "0")
|
||||
# KIS inquire_price 호환 + 키움 0B 원본 FID 보존 (분석·추후 필터용)
|
||||
exec_raw = values.get(self.FID_EXEC_STRENGTH, "0")
|
||||
try:
|
||||
cntr_str = float(str(cntr_raw).replace(",", ""))
|
||||
cntr_str = float(str(exec_raw).replace(",", ""))
|
||||
except (ValueError, TypeError):
|
||||
cntr_str = 0.0
|
||||
upper_limit_raw = str(values.get(self.FID_UPPER_LIMIT_TIME, "") or "").strip()
|
||||
lower_limit_raw = str(values.get(self.FID_LOWER_LIMIT_TIME, "") or "").strip()
|
||||
data_compat = {
|
||||
"stck_prpr": str(int(price)),
|
||||
"stck_oprc": _abs_str(values.get(self.FID_OPEN, "0")),
|
||||
@@ -780,18 +803,98 @@ class KiwoomWebSocketPriceCache:
|
||||
"prdy_vrss": _abs_str(values.get(self.FID_CHANGE, "0")),
|
||||
"prdy_ctrt": str(values.get(self.FID_CHANGE_PCT, "0")),
|
||||
"cntr_str": str(cntr_str),
|
||||
"upper_limit_time": upper_limit_raw,
|
||||
"lower_limit_time": lower_limit_raw,
|
||||
"kiwoom_fid228": str(exec_raw),
|
||||
"kiwoom_fid567": upper_limit_raw,
|
||||
"kiwoom_fid568": lower_limit_raw,
|
||||
}
|
||||
with self._cache_lock:
|
||||
self._cache[code] = {"data": data_compat, "ts": time.time()}
|
||||
self._emit_price_listeners(code, float(price), data_compat)
|
||||
|
||||
# tick_time / tick_vol — CandleAggregator·TickRecorder 공용 (보유-only도 recorder 수집)
|
||||
# 늦음 판정 = 이 틱의 체결시각(FID20) vs 우리 서버 지금. 증권사끼리 비교 아님.
|
||||
# 매매 RAM/리스너/봉: 읽기나이(LIVE_FEED_FALLBACK, 기본 3초) 넘으면 안 넣음 → 2차·3차가 연다.
|
||||
# 적재(ws_ticks): 버리지 않고 전부. LIVE_MAX<=0 이면 적재 skip 없음.
|
||||
# 아침 FID20 동결: 같은 FID20 가 TIME_MAX초 이상 안 움직일 때만 RAM 유지.
|
||||
import datetime as _dt
|
||||
tt_raw = str(values.get(self.FID_TICK_TIME, "") or "").strip()
|
||||
_now_dt = _dt.datetime.now()
|
||||
_now_wall = time.time()
|
||||
if len(tt_raw) >= 6:
|
||||
tick_time = tt_raw[-6:]
|
||||
tick_time_pkt = tt_raw[-6:]
|
||||
else:
|
||||
import datetime as _dt
|
||||
tick_time = _dt.datetime.now().strftime("%H%M%S")
|
||||
tick_time_pkt = _now_dt.strftime("%H%M%S")
|
||||
_pkt_dt = None
|
||||
try:
|
||||
if len(tt_raw) >= 14:
|
||||
_pkt_dt = _dt.datetime.strptime(tt_raw[:14], "%Y%m%d%H%M%S")
|
||||
elif len(tt_raw) >= 6:
|
||||
_pkt_dt = _dt.datetime.strptime(
|
||||
_now_dt.strftime("%Y%m%d") + tick_time_pkt, "%Y%m%d%H%M%S",
|
||||
)
|
||||
except Exception:
|
||||
_pkt_dt = None
|
||||
_lag_sec = 0.0
|
||||
if _pkt_dt is not None:
|
||||
_lag_sec = float((_now_dt - _pkt_dt).total_seconds())
|
||||
try:
|
||||
_live_max = int(get_env_int("KIWOOM_TICK_LIVE_MAX_LAG_SEC", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
_live_max = 0
|
||||
try:
|
||||
from kis_trader.engine.feed_fallback import live_feed_fallback_max_age_sec
|
||||
_read_max = float(live_feed_fallback_max_age_sec())
|
||||
except Exception:
|
||||
_read_max = 2.0
|
||||
_time_max = max(5, int(get_env_int("KIWOOM_TICK_TIME_MAX_LAG_SEC", 120)))
|
||||
_last_fid = self._fid20_last.get(code)
|
||||
if _pkt_dt is not None and _last_fid != _pkt_dt:
|
||||
self._fid20_first_wall[code] = _now_wall
|
||||
_first_w = float(self._fid20_first_wall.get(code, _now_wall) or _now_wall)
|
||||
# 같은 FID20 가 TIME_MAX초 이상 지속 = 아침 시계 멈춤. 밀린 줄의 같은 초 2건(수십 ms)은 제외.
|
||||
_frozen = bool(
|
||||
_pkt_dt is not None
|
||||
and _last_fid is not None
|
||||
and _pkt_dt == _last_fid
|
||||
and (_now_wall - _first_w) >= float(_time_max)
|
||||
)
|
||||
# 재연결 버퍼: FID20가 지금보다 읽기나이(2초) 이상 과거면 매매 RAM 미반영.
|
||||
# 적재는 기본 전부(LIVE_MAX=0). 양수일 때만 그 초 초과 미저장.
|
||||
_skip_ram = bool(
|
||||
_read_max > 0
|
||||
and _pkt_dt is not None
|
||||
and _lag_sec > float(_read_max)
|
||||
and not _frozen
|
||||
)
|
||||
_skip_persist = bool(
|
||||
_live_max > 0
|
||||
and _pkt_dt is not None
|
||||
and _lag_sec > float(_live_max)
|
||||
and not _frozen
|
||||
)
|
||||
if _pkt_dt is not None:
|
||||
self._fid20_last[code] = _pkt_dt
|
||||
|
||||
if not _skip_ram:
|
||||
wall_ts = time.time()
|
||||
with self._cache_lock:
|
||||
self._cache[code] = {"data": data_compat, "ts": wall_ts}
|
||||
self._emit_price_listeners(code, float(price), data_compat)
|
||||
else:
|
||||
_log_gap = max(5.0, float(get_env_float("KIWOOM_TICK_STALE_LOG_GAP_SEC", 30.0) or 30.0))
|
||||
_prev_log = float(self._stale_skip_log_ts.get(code, 0.0) or 0.0)
|
||||
if (time.time() - _prev_log) >= _log_gap:
|
||||
self._stale_skip_log_ts[code] = time.time()
|
||||
logger.warning(
|
||||
"키움 0B stale skip RAM %s fid20=%s lag=%.0fs price=%.0f (매수체크 미반영·2차 폴백)",
|
||||
code, tick_time_pkt, _lag_sec, price,
|
||||
)
|
||||
|
||||
tick_time = tick_time_pkt
|
||||
if _pkt_dt is None:
|
||||
tick_time = _now_dt.strftime("%H%M%S")
|
||||
elif _frozen and _lag_sec > float(_time_max):
|
||||
# FID20 동결(아침 55분 버그) — 분봉·틱청산 타임라인만 wall-clock
|
||||
tick_time = _now_dt.strftime("%H%M%S")
|
||||
try:
|
||||
tick_vol = int(
|
||||
abs(float(str(values.get(self.FID_TICK_VOL, "0")).replace(",", "")))
|
||||
@@ -800,7 +903,8 @@ class KiwoomWebSocketPriceCache:
|
||||
tick_vol = 0
|
||||
|
||||
# ── CandleAggregator: 후보만 (tick_to_agg = 후보−보유) ─────
|
||||
if self._candle_agg is not None:
|
||||
# 밀린 메인 틱은 확정봉에 넣지 않음 (freeze 오염 방지)
|
||||
if self._candle_agg is not None and not _skip_ram:
|
||||
filt = self._candle_agg_codes
|
||||
if filt is None or code in filt:
|
||||
try:
|
||||
@@ -809,16 +913,21 @@ class KiwoomWebSocketPriceCache:
|
||||
logger.debug("키움→CandleAggregator on_tick 실패 %s: %s", code, ex)
|
||||
|
||||
# ── TickRecorder: 보유 포함 전 구독 종목 (백테 틱청산·B안 폴백) ─────
|
||||
if self._tick_recorder is not None:
|
||||
# 공책은 밀려도 적재. 읽기 2초와 분리. 옵투나는 lag>2초 메인을 보조로 교체.
|
||||
if self._tick_recorder is not None and not _skip_persist:
|
||||
try:
|
||||
self._tick_recorder.on_tick(
|
||||
code, price, tick_vol, tick_time, source="kiwoom",
|
||||
cntr_str=cntr_str,
|
||||
upper_limit_time=upper_limit_raw or None,
|
||||
tick_time_raw=tt_raw or None,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.debug("키움→TickRecorder on_tick 실패 %s: %s", code, ex)
|
||||
|
||||
# ── 호가 틱동기: 체결 1건당 RAM 0D 스냅 1장 (LS tick 모드와 동일) ──
|
||||
if self._trigger_snapshot_recorder is not None:
|
||||
# 밀린 체결시각에 지금 호가를 붙이면 옵투나 호가가 미래가 됨 → RAM과 같이 skip.
|
||||
if self._trigger_snapshot_recorder is not None and not _skip_ram:
|
||||
try:
|
||||
max_age = float(
|
||||
getattr(
|
||||
@@ -852,32 +961,48 @@ class KiwoomWebSocketPriceCache:
|
||||
# ------------------------------------------------------------------
|
||||
# 내부: 등록/해지 메시지 발송
|
||||
# ------------------------------------------------------------------
|
||||
def _send_reg_chunked(self, codes: List[str]) -> None:
|
||||
"""REG 를 청크 단위로 전송하고 청크 사이에 sleep (키움 TRNM 건수 제한 회피)."""
|
||||
def _reg_refresh_keep(self) -> str:
|
||||
"""REG refresh. 기본 1(기존 유지). 0=그룹 기존 item/type 해지 — 테스트용."""
|
||||
v = int(get_env_int("KIWOOM_WS_REG_REFRESH", 1) or 1)
|
||||
return "0" if v == 0 else "1"
|
||||
|
||||
def _send_reg_chunked(self, codes: List[str], *, group_replace: bool = False) -> None:
|
||||
"""REG 를 청크 단위로 전송하고 청크 사이에 sleep (키움 TRNM 건수 제한 회피).
|
||||
|
||||
group_replace=True (LOGIN 일괄): ``KIWOOM_WS_REG_REFRESH=0`` 이면 **첫 청크만** refresh=0.
|
||||
이후 청크·장중 추가 REG는 항상 1 (0을 반복하면 앞 청크 구독이 삭제됨).
|
||||
"""
|
||||
if not codes:
|
||||
return
|
||||
chunk = self._reg_chunk_size()
|
||||
gap = self._reg_gap_sec()
|
||||
want = self._reg_refresh_keep() if group_replace else "1"
|
||||
if group_replace and want == "0":
|
||||
logger.warning(
|
||||
"⚠️ 키움 WS REG refresh=0 테스트 — LOGIN 일괄 첫 청크만 기존 구독 해지 후 재등록"
|
||||
)
|
||||
for i in range(0, len(codes), chunk):
|
||||
part = codes[i:i + chunk]
|
||||
self._send_reg(part)
|
||||
refresh = want if i == 0 else "1"
|
||||
self._send_reg(part, refresh=refresh)
|
||||
if i + chunk < len(codes):
|
||||
time.sleep(gap)
|
||||
|
||||
def _send_reg(self, codes: list) -> bool:
|
||||
def _send_reg(self, codes: list, *, refresh: str = "1") -> bool:
|
||||
"""REG 발송 (그룹 1, 0B+0D 타입). 한 번에 여러 종목 OK."""
|
||||
if not codes or not self._ws or not self._connected:
|
||||
return False
|
||||
flag = "0" if str(refresh).strip() == "0" else "1"
|
||||
try:
|
||||
self._ws.send(json.dumps({
|
||||
"trnm": "REG",
|
||||
"grp_no": self.GROUP_NO,
|
||||
"refresh": "1", # 재시작 시 등록 유지
|
||||
"refresh": flag,
|
||||
"data": [{"item": list(codes), "type": self._reg_types()}],
|
||||
}))
|
||||
logger.info(
|
||||
"📡 키움 WS REG 발송: %d종목 types=%s (총 %d/%d)",
|
||||
len(codes), self._reg_types(), self.subscribed_count(),
|
||||
"📡 키움 WS REG 발송: %d종목 types=%s refresh=%s (총 %d/%d)",
|
||||
len(codes), self._reg_types(), flag, self.subscribed_count(),
|
||||
self._max_subscriptions(),
|
||||
)
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user