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

@@ -18,6 +18,7 @@ permanent_subs.py
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger("permanent_subs")
@@ -39,11 +40,22 @@ CREATE TABLE IF NOT EXISTS permanent_subscriptions (
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``, 아니면 그대로."""
@@ -53,6 +65,23 @@ def _core(db: Any):
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()
@@ -88,11 +117,15 @@ def upsert_permanent_sub(
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()
@@ -109,26 +142,148 @@ def upsert_permanent_sub(
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(
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(
"""
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)
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
""",
[code, mt, ex, sym, tfv, 1 if enabled else 0, str(note or "")[:100]],
[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:
@@ -152,6 +307,7 @@ def normalize_all_tf_to_base(db: Any) -> int:
except Exception:
return 0
def remove_permanent_sub(db: Any, code: str) -> bool:
ensure_permanent_subs_table(db)
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]]:
ensure_permanent_subs_table(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:
sql += " WHERE enabled=1"
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:
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