Files
kis_bot/permanent_subs.py
2026-07-30 18:05:07 +09:00

320 lines
11 KiB
Python

"""
permanent_subs.py
=================
영구구독(Permanent WS Subscriptions) 통합 테이블 — **국내(KR)·해외(US) 단일 소스**.
배경:
- 기존 ``env_config.PERMANENT_WS_CODES`` 는 콤마 문자열이라 **시장/거래소/심볼 메타를
담지 못함.** 해외(QQQM 등)는 국내 WS(H0STCNT0)로는 못 받고, 거래소 코드가 필요하다.
- 이 테이블은 코드별로 ``market_type/exchange/symbol/tf_min`` 을 보관해
- KR → 국내 실시간 WS(H0STCNT0)
- US → 해외 실시간 WS(HDFSCNT0, tr_key=D{EXCD}{SYMBOL})
로 각각 라우팅할 수 있게 한다.
호환:
- ``env_config.PERMANENT_WS_CODES`` 는 폴백으로 유지(테이블이 비어 있을 때만).
- ``migrate_env_codes()`` 로 기존 콤마 코드를 1회 이관(중복은 건너뜀).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger("permanent_subs")
# 해외(US) 실시간 WS tr_key 거래소 코드 — 주문용(NASD/NYSE/AMEX) → 시세용(NAS/NYS/AMS)
_US_EXCD_MAP = {
"NASD": "NAS", "NAS": "NAS",
"NYSE": "NYS", "NYS": "NYS",
"AMEX": "AMS", "AMS": "AMS",
}
_PERM_DDL = """
CREATE TABLE IF NOT EXISTS permanent_subscriptions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(16) NOT NULL,
market_type VARCHAR(8) NOT NULL DEFAULT 'KR',
exchange VARCHAR(16) NOT NULL DEFAULT 'KRX',
symbol VARCHAR(32) NOT NULL DEFAULT '',
tf_min INT NOT NULL DEFAULT 1,
enabled TINYINT NOT NULL DEFAULT 1,
note VARCHAR(100) NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_perm_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='영구구독(국내 WS + 해외 WS) 종목'
"""
def _core(db: Any):
"""TradeDBExt 이면 ``.raw``, 아니면 그대로."""
return getattr(db, "raw", db)
def ensure_permanent_subs_table(db: Any) -> None:
raw = _core(db)
raw.conn.execute(_PERM_DDL.strip())
raw.conn.commit()
def classify_market(code: str) -> tuple[str, str, str]:
"""
코드 형태로 (market_type, exchange, symbol) 추정.
- 6자리 숫자 → KR/KRX
- 영문 1~8자 → US/NASD (거래소는 이후 UI/DB에서 보정 가능)
"""
c = str(code or "").strip().upper()
if c.isdigit() and len(c) == 6:
return "KR", "KRX", c
if c.isalpha() and 1 <= len(c) <= 8:
return "US", "NASD", c
# 알 수 없으면 KR 취급(보수적)
return "KR", "KRX", c
def us_ws_tr_key(exchange: str, symbol: str) -> str:
"""해외 실시간 WS tr_key — D + 시세거래소(NAS/NYS/AMS) + 심볼 (예: DNASQQQM)."""
ex = str(exchange or "NASD").strip().upper()
excd = _US_EXCD_MAP.get(ex, "NAS")
sym = str(symbol or "").strip().upper()
return f"D{excd}{sym}"
def upsert_permanent_sub(
db: Any,
code: str,
market_type: Optional[str] = None,
exchange: Optional[str] = None,
symbol: Optional[str] = None,
tf_min: int = 1,
enabled: bool = True,
note: str = "",
) -> None:
"""코드 1건 등록/갱신 (code UNIQUE).
tf_min 은 저장·표시용 기준봉. WS는 틱→1분 집계가 기본이고 상위봉은 롤업하므로
기본·권장은 1분 (env ``PERM_SUB_BASE_TF_MIN``).
"""
ensure_permanent_subs_table(db)
code = str(code or "").strip().upper()
if not code:
raise ValueError("code 필수")
g_mt, g_ex, g_sym = classify_market(code)
mt = (str(market_type).strip().upper() if market_type else "") or g_mt
ex = (str(exchange).strip().upper() if exchange else "") or (g_ex if mt == g_mt else ("KRX" if mt == "KR" else "NASD"))
sym = (str(symbol).strip().upper() if symbol else "") or g_sym or code
try:
from kis_trader.utils.env import get_env_int
base_tf = int(get_env_int("PERM_SUB_BASE_TF_MIN", 1))
except Exception:
base_tf = 1
if base_tf < 1:
base_tf = 1
# 요청값과 무관하게 기준봉으로 통일 (1분→롤업)
try:
_ = int(tf_min)
except (TypeError, ValueError):
pass
tfv = base_tf
raw = _core(db)
raw.conn.execute(
"""
INSERT INTO permanent_subscriptions
(code, market_type, exchange, symbol, tf_min, enabled, note)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
market_type=VALUES(market_type), exchange=VALUES(exchange),
symbol=VALUES(symbol), tf_min=VALUES(tf_min),
enabled=VALUES(enabled), note=VALUES(note)
""",
[code, mt, ex, sym, tfv, 1 if enabled else 0, str(note or "")[:100]],
)
raw.conn.commit()
def normalize_all_tf_to_base(db: Any) -> int:
"""기존 영구구독 tf_min 을 기준봉(기본 1)으로 일괄 정리. 변경 건수 반환."""
ensure_permanent_subs_table(db)
try:
from kis_trader.utils.env import get_env_int
base_tf = int(get_env_int("PERM_SUB_BASE_TF_MIN", 1))
except Exception:
base_tf = 1
if base_tf < 1:
base_tf = 1
raw = _core(db)
cur = raw.conn.execute(
"UPDATE permanent_subscriptions SET tf_min=%s WHERE tf_min<>%s",
[base_tf, base_tf],
)
raw.conn.commit()
try:
return int(cur.rowcount or 0)
except Exception:
return 0
def remove_permanent_sub(db: Any, code: str) -> bool:
ensure_permanent_subs_table(db)
code = str(code or "").strip().upper()
if not code:
return False
raw = _core(db)
cur = raw.conn.execute("DELETE FROM permanent_subscriptions WHERE code=%s", [code])
raw.conn.commit()
try:
return bool(cur.rowcount)
except Exception:
return True
def set_enabled(db: Any, code: str, enabled: bool) -> None:
ensure_permanent_subs_table(db)
raw = _core(db)
raw.conn.execute(
"UPDATE permanent_subscriptions SET enabled=%s WHERE code=%s",
[1 if enabled else 0, str(code or "").strip().upper()],
)
raw.conn.commit()
def list_permanent_subs(db: Any, enabled_only: bool = False) -> List[Dict[str, Any]]:
ensure_permanent_subs_table(db)
raw = _core(db)
sql = "SELECT code, market_type, exchange, symbol, tf_min, enabled, note FROM permanent_subscriptions"
if enabled_only:
sql += " WHERE enabled=1"
sql += " ORDER BY market_type, code"
cur = raw.conn.execute(sql)
rows = cur.fetchall() if cur else []
out: List[Dict[str, Any]] = []
for r in rows:
d = dict(r) if not isinstance(r, dict) else dict(r)
d["enabled"] = int(d.get("enabled", 1))
out.append(d)
return out
def codes_by_market(db: Any, market_type: str, enabled_only: bool = True) -> List[Dict[str, Any]]:
"""특정 시장(KR/US) 영구구독 목록 (WS 배선용)."""
mt = str(market_type or "").strip().upper()
return [r for r in list_permanent_subs(db, enabled_only=enabled_only)
if str(r.get("market_type", "KR")).strip().upper() == mt]
def migrate_env_codes(db: Any, env_csv: str) -> int:
"""
기존 ``PERMANENT_WS_CODES`` 콤마 문자열 → 테이블 1회 이관.
이미 있는 code 는 건드리지 않음(덮어쓰기 방지). 신규 삽입 건수 반환.
"""
ensure_permanent_subs_table(db)
existing = {r["code"] for r in list_permanent_subs(db)}
n = 0
for raw_code in str(env_csv or "").split(","):
c = raw_code.strip().upper()
if not c or c in existing:
continue
mt, ex, sym = classify_market(c)
upsert_permanent_sub(db, c, mt, ex, sym, note="env 이관")
existing.add(c)
n += 1
return n
def last_quotes_for_codes(
db: Any,
codes: List[str],
tf_min: int = 1,
) -> Dict[str, Dict[str, Any]]:
"""영구구독 UI용 현재가 — REST/틱 전수스캔 없이 ``ws_candles`` 만.
※ 예전 ``ws_ticks`` ROW_NUMBER 전종목 스캔은 수십 초 지연 → UI 절대 금지.
이 함수는 ``ws_candles`` 만 조회한다. ws_ticks 경로를 다시 넣지 말 것.
반환 code → {
price, price_src ('candle'|''), candle_time, tick_time(항상 ''),
chg_pct (직전 봉 종가 대비 %), volume, updated_at
}
"""
from datetime import datetime as _dt
out: Dict[str, Dict[str, Any]] = {}
uniq = []
seen = set()
for c in codes or []:
u = str(c or "").strip().upper()
if not u or u in seen:
continue
seen.add(u)
uniq.append(u)
out[u] = {
"price": 0.0,
"price_src": "",
"candle_time": "",
"tick_time": "",
"chg_pct": None,
"volume": 0,
"updated_at": "",
}
if not uniq:
return out
try:
tf = int(tf_min) if int(tf_min) >= 1 else 1
except (TypeError, ValueError):
tf = 1
# 미래 슬롯(이상봉) 제외 — 로컬 now + 2분 여유
try:
now_slot = (_dt.now()).strftime("%Y%m%d%H%M")
except Exception:
now_slot = "999999999999"
raw = _core(db)
for code in uniq:
try:
rows = raw.conn.execute(
"""
SELECT candle_time, open, close, volume, updated_at
FROM ws_candles
WHERE code=%s AND timeframe=%s AND candle_time<=%s
ORDER BY candle_time DESC
LIMIT 2
""",
[code, tf, now_slot],
).fetchall() or []
except Exception as e:
logger.debug("last_quotes candle %s: %s", code, e)
rows = []
if not rows:
continue
parsed: List[Dict[str, Any]] = []
for r in rows:
parsed.append(dict(r) if not isinstance(r, dict) else dict(r))
last = parsed[0]
try:
px = float(last.get("close") or 0)
except (TypeError, ValueError):
px = 0.0
if px <= 0:
try:
px = float(last.get("open") or 0)
except (TypeError, ValueError):
px = 0.0
chg = None
if len(parsed) >= 2 and px > 0:
try:
prev = float(parsed[1].get("close") or 0)
if prev > 0:
chg = (px - prev) / prev * 100.0
except (TypeError, ValueError, ZeroDivisionError):
chg = None
try:
vol = int(last.get("volume") or 0)
except (TypeError, ValueError):
vol = 0
out[code].update({
"price": px,
"price_src": "candle" if px > 0 else "",
"candle_time": str(last.get("candle_time") or ""),
"chg_pct": chg,
"volume": vol,
"updated_at": str(last.get("updated_at") or ""),
})
return out