Files
kis_trader/permanent_subs.py
Your Name a0fe66bc11 feat(영구구독): 목표가 MM 알람 폴링·perm_price_alert 헬퍼
영구구독 종목 목표가 도달 시 Mattermost 알림을 heartbeat 폴링으로 검사한다.
permanent_subs CLI/웹 연동용 가격 알람 헬퍼를 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:46:16 +09:00

636 lines
22 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 datetime import datetime
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 '',
alert_price DOUBLE NULL,
alert_side VARCHAR(8) NOT NULL DEFAULT 'gte',
alert_armed TINYINT NOT NULL DEFAULT 1,
alert_fired_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_perm_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='영구구독(국내 WS + 해외 WS) 종목'
"""
_PERM_ALERT_COLS = (
("alert_price", "DOUBLE NULL COMMENT '목표가 알람 가격(NULL=미설정)'"),
("alert_side", "VARCHAR(8) NOT NULL DEFAULT 'gte' COMMENT 'gte=이상 lte=이하'"),
("alert_armed", "TINYINT NOT NULL DEFAULT 1 COMMENT '1=감시중 0=발화후대기'"),
("alert_fired_at", "DATETIME NULL COMMENT '마지막 알람 시각'"),
)
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())
# 기존 테이블에 알람 컬럼 보강
try:
cols = {c["Field"] for c in raw.conn.execute(
"SHOW COLUMNS FROM permanent_subscriptions"
).fetchall() or []}
except Exception:
cols = set()
for name, ddl in _PERM_ALERT_COLS:
if name in cols:
continue
try:
raw.conn.execute(
f"ALTER TABLE permanent_subscriptions ADD COLUMN `{name}` {ddl}"
)
logger.info("📌 permanent_subscriptions.%s 컬럼 추가", name)
except Exception as e:
logger.debug("perm alert col %s: %s", name, e)
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 = "",
alert_price: Optional[Any] = None,
alert_side: Optional[str] = None,
update_alert: bool = False,
) -> None:
"""코드 1건 등록/갱신 (code UNIQUE).
tf_min 은 저장·표시용 기준봉. WS는 틱→1분 집계가 기본이고 상위봉은 롤업하므로
기본·권장은 1분 (env ``PERM_SUB_BASE_TF_MIN``).
update_alert=True 일 때만 alert_* 를 덮어씀 (일반 저장 시 알람값 유지).
"""
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
try:
_ = int(tf_min)
except (TypeError, ValueError):
pass
tfv = base_tf
raw = _core(db)
if update_alert:
ap = None
if alert_price not in (None, ""):
try:
ap = float(alert_price)
if ap <= 0:
ap = None
except (TypeError, ValueError):
ap = None
aside = str(alert_side or "gte").strip().lower()
if aside not in ("gte", "lte"):
aside = "gte"
# 목표가 변경/설정 시 재무장
armed = 1 if ap is not None else 0
raw.conn.execute(
"""
INSERT INTO permanent_subscriptions
(code, market_type, exchange, symbol, tf_min, enabled, note,
alert_price, alert_side, alert_armed, alert_fired_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NULL)
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),
alert_price=VALUES(alert_price), alert_side=VALUES(alert_side),
alert_armed=VALUES(alert_armed), alert_fired_at=NULL
""",
[code, mt, ex, sym, tfv, 1 if enabled else 0, str(note or "")[:100],
ap, aside, armed],
)
else:
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 set_alert(
db: Any,
code: str,
alert_price: Optional[Any] = None,
alert_side: str = "gte",
rearm: bool = True,
) -> bool:
"""목표가 알람만 갱신. alert_price 비우면 해제."""
ensure_permanent_subs_table(db)
code = str(code or "").strip().upper()
if not code:
return False
ap = None
if alert_price not in (None, ""):
try:
ap = float(alert_price)
if ap <= 0:
ap = None
except (TypeError, ValueError):
ap = None
aside = str(alert_side or "gte").strip().lower()
if aside not in ("gte", "lte"):
aside = "gte"
armed = 1 if (ap is not None and rearm) else 0
raw = _core(db)
cur = raw.conn.execute(
"""
UPDATE permanent_subscriptions
SET alert_price=%s, alert_side=%s, alert_armed=%s,
alert_fired_at=IF(%s IS NULL, NULL, NULL)
WHERE code=%s
""",
[ap, aside, armed, ap, code],
)
raw.conn.commit()
try:
return bool(cur.rowcount)
except Exception:
return True
def rearm_alert(db: Any, code: str) -> bool:
"""발화 후 재감시."""
ensure_permanent_subs_table(db)
code = str(code or "").strip().upper()
raw = _core(db)
cur = raw.conn.execute(
"""
UPDATE permanent_subscriptions
SET alert_armed=1
WHERE code=%s AND alert_price IS NOT NULL AND alert_price>0
""",
[code],
)
raw.conn.commit()
try:
return bool(cur.rowcount)
except Exception:
return True
def mark_alert_fired(db: Any, code: str) -> None:
ensure_permanent_subs_table(db)
raw = _core(db)
raw.conn.execute(
"""
UPDATE permanent_subscriptions
SET alert_armed=0, alert_fired_at=%s
WHERE code=%s
""",
[datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
str(code or "").strip().upper()],
)
raw.conn.commit()
def list_armed_alerts(db: Any) -> List[Dict[str, Any]]:
"""감시 중인 목표가 행."""
ensure_permanent_subs_table(db)
raw = _core(db)
rows = raw.conn.execute(
"""
SELECT code, market_type, exchange, symbol, note,
alert_price, alert_side, alert_armed, alert_fired_at
FROM permanent_subscriptions
WHERE enabled=1 AND alert_armed=1
AND alert_price IS NOT NULL AND alert_price>0
"""
).fetchall() or []
return [dict(r) for r in rows]
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, "
"alert_price, alert_side, alert_armed, alert_fired_at "
"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))
try:
d["alert_armed"] = int(d.get("alert_armed", 1) or 0)
except (TypeError, ValueError):
d["alert_armed"] = 0
out.append(d)
return out
def subscribe_master_enabled() -> bool:
"""마스터 스위치 — OFF면 행은 두고 구독만 안 함."""
from kis_trader.utils.env import get_env_bool
return get_env_bool("PERMANENT_SUBSCRIBE_ENABLED", True)
def enabled_code_set(db: Any, market_type: Optional[str] = None) -> set:
"""enabled=1 영구구독 코드. 마스터와 무관 (저장 가드는 이 집합)."""
mt = str(market_type or "").strip().upper()
out = set()
for r in list_permanent_subs(db, enabled_only=True):
if mt and str(r.get("market_type") or "KR").strip().upper() != mt:
continue
c = str(r.get("code") or "").strip()
if c:
out.add(c)
return out
def subscribe_codes(db: Any, market_type: str) -> List[Dict[str, Any]]:
"""실제 WS 구독에 쓸 목록 = 마스터 ON ∧ enabled."""
if not subscribe_master_enabled():
return []
return codes_by_market(db, market_type, enabled_only=True)
def should_persist_ls(code: str, perm_codes: Optional[set] = None) -> bool:
"""LS 봉·VI DB 적재: 영구구독 enabled 코드만.
체결틱·호가 DB 는 ``KISTrader._ls_is_subscribed``(구독 전체) — 여기 쓰지 말 것.
"""
c = str(code or "").strip()
if not c:
return False
if perm_codes is None:
return False
return c in perm_codes
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:
rows = []
# KR 영구구독 시세는 ls_ws_candles 우선 (키움/KIS 슬롯 이관)
if code.isdigit() and len(code) == 6:
try:
now_ls = ""
if len(now_slot) >= 12:
now_ls = (
f"{now_slot[0:4]}-{now_slot[4:6]}-{now_slot[6:8]} "
f"{now_slot[8:10]}:{now_slot[10:12]}:00"
)
ls_rows = raw.conn.execute(
"""
SELECT datetime, open, close, volume, updated_at
FROM ls_ws_candles
WHERE code=%s AND tf_min=%s AND datetime<=%s
ORDER BY datetime DESC
LIMIT 2
""",
[code, tf, now_ls or "9999-12-31 23:59:00"],
).fetchall() or []
for r in ls_rows:
d = dict(r) if not isinstance(r, dict) else dict(r)
dt = str(d.get("datetime") or "")
digits = "".join(ch for ch in dt if ch.isdigit())[:12]
d["candle_time"] = digits
rows.append(d)
except Exception as e:
logger.debug("last_quotes ls_ws_candles %s: %s", code, e)
rows = []
if not rows:
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
def fill_ls_candles_from_kiwoom(
db: Any,
codes: List[str],
*,
n_bars: Optional[int] = None,
tf_min: int = 1,
) -> Dict[str, Any]:
"""키움 ka10080 → ls_ws_candles INSERT IGNORE (구멍만). ws_candles 에 넣지 않음."""
import random
import time as _time
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from kis_trader.ws.kis_ws import get_kiwoom_candles_df
from database import TradeDB
tf = max(1, int(tf_min or 1))
n_req = int(n_bars) if n_bars else int(get_env_int("PERM_LS_FILL_BARS", 500))
sleep_lo = float(get_env_float("PERM_LS_FILL_SLEEP_MIN", 1.0))
sleep_hi = float(get_env_float("PERM_LS_FILL_SLEEP_MAX", 3.0))
if sleep_hi < sleep_lo:
sleep_hi = sleep_lo
force_real = get_env_bool("KIWOOM_WS_FORCE_REAL", True)
is_mock = False if force_real else get_env_bool("KIS_MOCK", False)
if is_mock:
key = str(get_env_from_db("KIWOOM_APP_KEY_MOCK", "") or "").strip()
sec = str(get_env_from_db("KIWOOM_APP_SECRET_MOCK", "") or "").strip()
else:
key = str(get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
sec = str(get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()
if not key or not sec:
key = str(get_env_from_db("KIWOOM_APP_KEY", "") or "").strip()
sec = str(get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip()
if not key or not sec:
return {"ok": False, "error": "키움 API 키 없음", "codes": []}
raw = _core(db)
out_codes: List[Dict[str, Any]] = []
for i, code in enumerate(codes or []):
c = str(code or "").strip()
if not (c.isdigit() and len(c) == 6):
out_codes.append({"code": c, "ok": False, "error": "KR 6자리만", "inserted": 0})
continue
inserted = 0
err = ""
try:
df = get_kiwoom_candles_df(c, tf, key, sec, is_mock=is_mock, n=n_req)
if df is None or getattr(df, "empty", True):
err = "빈응답"
else:
for _, rec in df.iterrows():
ct = str(rec.get("time") or "")[:12]
if len(ct) < 12:
continue
close = float(rec.get("close") or 0)
if close <= 0:
continue
dt = TradeDB._candle_time_to_ls_datetime(ct)
if not dt:
continue
candle = {
"datetime": dt,
"tf_min": tf,
"open": float(rec.get("open") or close),
"high": float(rec.get("high") or close),
"low": float(rec.get("low") or close),
"close": close,
"volume": float(rec.get("volume") or 0),
"tick_count": 0,
}
if raw.insert_ls_ws_candle_if_absent(code=c, candle=candle):
inserted += 1
except Exception as e:
err = str(e)
out_codes.append({
"code": c, "ok": not err, "error": err, "inserted": inserted,
})
if i + 1 < len(codes or []):
_time.sleep(random.uniform(sleep_lo, sleep_hi))
ok_n = sum(1 for x in out_codes if x.get("ok"))
return {"ok": True, "codes": out_codes, "ok_n": ok_n}