feat(영구구독): 목표가 MM 알람 폴링·perm_price_alert 헬퍼

영구구독 종목 목표가 도달 시 Mattermost 알림을 heartbeat 폴링으로 검사한다.
permanent_subs CLI/웹 연동용 가격 알람 헬퍼를 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-08-28 16:46:16 +09:00
parent 134fffa39a
commit a0fe66bc11
2 changed files with 361 additions and 11 deletions

View File

@@ -0,0 +1,186 @@
"""
영구구독 목표가 도달 → Mattermost 알람.
- permanent_subscriptions.alert_price / alert_side / alert_armed
- 시세: KR=WSManager(LS 우선)·US=해외 WS RAM
- 채널: KIS_PERM_SUB_MM_CHANNEL (기본 alias=permanent)
"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger("perm_price_alert")
def _px_from_dict(d: Optional[Dict]) -> float:
if not d:
return 0.0
for k in ("stck_prpr", "last", "price", "close"):
try:
v = float(str(d.get(k) or "").replace(",", "").strip())
if v > 0:
return v
except (TypeError, ValueError):
continue
return 0.0
def resolve_live_price(
code: str,
market_type: str,
*,
ws_manager: Any = None,
overseas_ws: Any = None,
) -> float:
"""실시간(또는 지연) 현재가. 없으면 0."""
c = str(code or "").strip().upper()
if not c:
return 0.0
mt = str(market_type or "KR").strip().upper()
if mt == "US":
if overseas_ws is not None and hasattr(overseas_ws, "get_price"):
try:
return _px_from_dict(overseas_ws.get_price(c, max_age_sec=None))
except Exception:
return 0.0
return 0.0
# KR: LS → 통합 inquire 경로
if ws_manager is None:
return 0.0
try:
ls = getattr(ws_manager, "_get_ls_ws", lambda: None)()
if ls is not None and hasattr(ls, "get_price"):
px = _px_from_dict(ls.get_price(c, max_age_sec=None))
if px > 0:
return px
except Exception:
pass
try:
if hasattr(ws_manager, "get_price"):
return _px_from_dict(ws_manager.get_price(c, max_age_sec=None))
except Exception:
pass
try:
# 폴백: 벤더 체인
for v in ("ls", "kiwoom", "kis"):
if hasattr(ws_manager, "_vendor_price"):
px = _px_from_dict(ws_manager._vendor_price(v, c, None))
if px > 0:
return px
except Exception:
pass
return 0.0
def _condition_met(px: float, target: float, side: str) -> bool:
if px <= 0 or target <= 0:
return False
s = (side or "gte").strip().lower()
if s in ("lte", "below", "down", "<="):
return px <= target
# 기본: 목표가 이상 (도달/돌파)
return px >= target
def tick_perm_price_alerts(
db: Any,
*,
ws_manager: Any = None,
overseas_ws: Any = None,
send_mm: Optional[Callable[[str, str], bool]] = None,
) -> int:
"""
armed 목표가 검사 1회. 도달 시 MM 발송 후 armed=0.
반환: 발송 시도 건수.
"""
from kis_trader.utils.env import get_env_bool, get_env_from_db
if not get_env_bool("PERM_ALERT_ENABLED", True):
return 0
try:
import permanent_subs as ps
except Exception as e:
logger.debug("perm_price_alert import: %s", e)
return 0
try:
rows = ps.list_armed_alerts(db)
except Exception as e:
logger.debug("list_armed_alerts: %s", e)
return 0
if not rows:
return 0
ch = (
get_env_from_db("KIS_PERM_SUB_MM_CHANNEL", "permanent") or "permanent"
).strip() or "permanent"
n = 0
for r in rows:
code = str(r.get("code") or "").strip().upper()
mt = str(r.get("market_type") or "KR").strip().upper()
try:
target = float(r.get("alert_price") or 0)
except (TypeError, ValueError):
target = 0.0
side = str(r.get("alert_side") or "gte").strip().lower()
if not code or target <= 0:
continue
px = resolve_live_price(
code, mt, ws_manager=ws_manager, overseas_ws=overseas_ws,
)
if not _condition_met(px, target, side):
continue
side_kr = "이하" if side in ("lte", "below", "down", "<=") else "이상"
name = ""
try:
if mt == "US":
name = str(r.get("symbol") or code).strip().upper()
else:
from kis_trader.utils.stock_name import resolve_stock_display_name
name = resolve_stock_display_name(db, code, fallback="") or ""
if name == code:
name = ""
except Exception:
name = ""
title = f"**{code}**"
if name and name != code:
title += f" {name}"
if mt == "US":
body = (
f"📡 영구구독 목표가\n"
f"{title} ({mt})\n"
f"현재가 **${px:.4f}** → 지정가 **${target:.4f}** ({side_kr}) 도달"
)
else:
body = (
f"📡 영구구독 목표가\n"
f"{title} ({mt})\n"
f"현재가 **{px:,.0f}원** → 지정가 **{target:,.0f}원** ({side_kr}) 도달"
)
note = str(r.get("note") or "").strip()
if note:
body += f"\n메모: {note}"
ok = False
if send_mm:
try:
ok = bool(send_mm(body, ch))
except Exception as e:
logger.warning("perm alert MM 실패 %s: %s", code, e)
else:
try:
from kis_trader.utils.logger import msg_mm
ok = bool(msg_mm(body, channel_alias=ch, jitter=False))
except Exception as e:
logger.warning("perm alert MM 실패 %s: %s", code, e)
try:
ps.mark_alert_fired(db, code)
except Exception as e:
logger.warning("mark_alert_fired %s: %s", code, e)
n += 1
logger.info(
"📡 목표가알람 %s px=%s target=%s side=%s mm=%s",
code, px, target, side, ok,
)
time.sleep(0.05)
return n

View File

@@ -18,6 +18,7 @@ permanent_subs.py
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
logger = logging.getLogger("permanent_subs") logger = logging.getLogger("permanent_subs")
@@ -39,11 +40,22 @@ CREATE TABLE IF NOT EXISTS permanent_subscriptions (
tf_min INT NOT NULL DEFAULT 1, tf_min INT NOT NULL DEFAULT 1,
enabled TINYINT NOT NULL DEFAULT 1, enabled TINYINT NOT NULL DEFAULT 1,
note VARCHAR(100) NOT NULL DEFAULT '', 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, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_perm_code (code) UNIQUE KEY uq_perm_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='영구구독(국내 WS + 해외 WS) 종목' ) 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): def _core(db: Any):
"""TradeDBExt 이면 ``.raw``, 아니면 그대로.""" """TradeDBExt 이면 ``.raw``, 아니면 그대로."""
@@ -53,6 +65,23 @@ def _core(db: Any):
def ensure_permanent_subs_table(db: Any) -> None: def ensure_permanent_subs_table(db: Any) -> None:
raw = _core(db) raw = _core(db)
raw.conn.execute(_PERM_DDL.strip()) 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() raw.conn.commit()
@@ -88,11 +117,15 @@ def upsert_permanent_sub(
tf_min: int = 1, tf_min: int = 1,
enabled: bool = True, enabled: bool = True,
note: str = "", note: str = "",
alert_price: Optional[Any] = None,
alert_side: Optional[str] = None,
update_alert: bool = False,
) -> None: ) -> None:
"""코드 1건 등록/갱신 (code UNIQUE). """코드 1건 등록/갱신 (code UNIQUE).
tf_min 은 저장·표시용 기준봉. WS는 틱→1분 집계가 기본이고 상위봉은 롤업하므로 tf_min 은 저장·표시용 기준봉. WS는 틱→1분 집계가 기본이고 상위봉은 롤업하므로
기본·권장은 1분 (env ``PERM_SUB_BASE_TF_MIN``). 기본·권장은 1분 (env ``PERM_SUB_BASE_TF_MIN``).
update_alert=True 일 때만 alert_* 를 덮어씀 (일반 저장 시 알람값 유지).
""" """
ensure_permanent_subs_table(db) ensure_permanent_subs_table(db)
code = str(code or "").strip().upper() code = str(code or "").strip().upper()
@@ -109,13 +142,43 @@ def upsert_permanent_sub(
base_tf = 1 base_tf = 1
if base_tf < 1: if base_tf < 1:
base_tf = 1 base_tf = 1
# 요청값과 무관하게 기준봉으로 통일 (1분→롤업)
try: try:
_ = int(tf_min) _ = int(tf_min)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
tfv = base_tf tfv = base_tf
raw = _core(db) 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( raw.conn.execute(
""" """
INSERT INTO permanent_subscriptions INSERT INTO permanent_subscriptions
@@ -131,6 +194,98 @@ def upsert_permanent_sub(
raw.conn.commit() 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: def normalize_all_tf_to_base(db: Any) -> int:
"""기존 영구구독 tf_min 을 기준봉(기본 1)으로 일괄 정리. 변경 건수 반환.""" """기존 영구구독 tf_min 을 기준봉(기본 1)으로 일괄 정리. 변경 건수 반환."""
ensure_permanent_subs_table(db) ensure_permanent_subs_table(db)
@@ -152,6 +307,7 @@ def normalize_all_tf_to_base(db: Any) -> int:
except Exception: except Exception:
return 0 return 0
def remove_permanent_sub(db: Any, code: str) -> bool: def remove_permanent_sub(db: Any, code: str) -> bool:
ensure_permanent_subs_table(db) ensure_permanent_subs_table(db)
code = str(code or "").strip().upper() code = str(code or "").strip().upper()
@@ -179,7 +335,11 @@ def set_enabled(db: Any, code: str, enabled: bool) -> None:
def list_permanent_subs(db: Any, enabled_only: bool = False) -> List[Dict[str, Any]]: def list_permanent_subs(db: Any, enabled_only: bool = False) -> List[Dict[str, Any]]:
ensure_permanent_subs_table(db) ensure_permanent_subs_table(db)
raw = _core(db) raw = _core(db)
sql = "SELECT code, market_type, exchange, symbol, tf_min, enabled, note FROM permanent_subscriptions" 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: if enabled_only:
sql += " WHERE enabled=1" sql += " WHERE enabled=1"
sql += " ORDER BY market_type, code" sql += " ORDER BY market_type, code"
@@ -189,6 +349,10 @@ def list_permanent_subs(db: Any, enabled_only: bool = False) -> List[Dict[str, A
for r in rows: for r in rows:
d = dict(r) if not isinstance(r, dict) else dict(r) d = dict(r) if not isinstance(r, dict) else dict(r)
d["enabled"] = int(d.get("enabled", 1)) 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) out.append(d)
return out return out