영구구독 종목 목표가 도달 시 Mattermost 알림을 heartbeat 폴링으로 검사한다. permanent_subs CLI/웹 연동용 가격 알람 헬퍼를 추가한다. Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
5.8 KiB
Python
187 lines
5.8 KiB
Python
"""
|
|
영구구독 목표가 도달 → 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
|