Files
kis_bot/kis_long_alert.py
2026-02-22 21:42:41 +09:00

268 lines
10 KiB
Python

#!/usr/bin/env python3
"""
KIS Long Alert - 늘림목 알림 전용 (매매 없음, 하루 2회 Mattermost 브리핑)
- kis_long_ver1.py에서 알림(리포트) 로직만 분리
- 13:00 오전 브리핑, 15:35 장마감 최종 브리핑 (환경변수로 시각 변경 가능)
"""
import json
import logging
import time
from pathlib import Path
from datetime import datetime as dt
from database import TradeDB
# kis_long_ver1과 동일한 설정·클래스 사용 (알림만 필요)
from kis_long_ver1 import (
db,
get_env_from_db,
get_env_float,
get_env_bool,
SCRIPT_DIR,
MM_SERVER_URL,
MM_BOT_TOKEN,
MM_CONFIG_FILE,
MM_CHANNEL_LONG,
MattermostBot,
KISClientWithOrder,
)
logging.basicConfig(
format="[%(asctime)s] %(message)s",
datefmt="%H:%M:%S",
level=logging.INFO,
)
logger = logging.getLogger("KISLongAlert")
# 알림 발송 시각 (하루 2회). DB/환경: ALERT_TIME_1="13:00", ALERT_TIME_2="15:35"
def _parse_time(s: str):
"""'13:00' -> (13, 0)"""
try:
h, m = s.strip().split(":")
return int(h), int(m)
except Exception:
return None
def _get_alert_times():
t1 = get_env_from_db("ALERT_TIME_1", "13:00").strip()
t2 = get_env_from_db("ALERT_TIME_2", "15:35").strip()
return _parse_time(t1) or (13, 0), _parse_time(t2) or (15, 35)
# 당일 시작 자산 저장 (첫 알림 시점에 설정, 같은 날 재사용)
STATE_FILE = SCRIPT_DIR / "kis_long_alert_state.json"
def _load_state():
try:
if STATE_FILE.exists():
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.debug(f"상태 파일 로드 실패: {e}")
return {}
def _save_state(state):
try:
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"상태 파일 저장 실패: {e}")
class LongAlertBot:
"""늘림목 알림 전용 - KIS 자산 조회 + Mattermost 2회 전송 (ver1과 동일한 보유 기준: DB LONG + watchlist 병합)"""
def __init__(self):
self.db = db
self.client = KISClientWithOrder()
self.mm = MattermostBot()
self.mm_channel = MM_CHANNEL_LONG
self.current_cash = 0
self.current_total_asset = 0
self.start_day_asset = 0
self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)
self.today_date = dt.now().strftime("%Y-%m-%d")
self.holdings = {}
# ver1과 동일: DB에서 늘림목(LONG) 보유만 로드
for code, trade in self.db.get_active_trades(strategy_prefix="LONG").items():
qty = trade.get("current_qty", 0) or 0
if qty > 0:
self.holdings[code] = {"total_qty": qty, "name": trade.get("name", code)}
# watchlist 로드 (새로 발굴·반영한 종목도 알림에 포함되도록)
self.watchlist_path = SCRIPT_DIR / "long_term_watchlist.json"
self.watchlist = self._load_watchlist()
self._update_assets()
self._maybe_set_start_day_asset()
def _load_watchlist(self):
if not self.watchlist_path.exists():
return []
try:
with open(self.watchlist_path, "r", encoding="utf-8") as f:
return json.load(f).get("items", [])
except Exception as e:
logger.debug(f"watchlist 로드 실패: {e}")
return []
def _merge_watchlist_holdings_from_balance(self, balance):
"""ver1과 동일: watchlist LONG/dca인 계좌 보유를 holdings에 합침 (inquire-balance → output1=종목 리스트)"""
positions_list = balance.get("output1") or []
if isinstance(positions_list, dict):
positions_list = [positions_list]
if not isinstance(positions_list, list):
positions_list = []
watchlist_dca_codes = {
(item.get("code") or "").strip()
for item in self.watchlist
if (item.get("strategy") == "LONG" or item.get("use") == "dca")
}
watchlist_dca_codes.discard("")
for row in positions_list:
code = (row.get("pdno") or "").strip()
if not code or code in self.holdings or code not in watchlist_dca_codes:
continue
try:
qty = int(float(row.get("hldg_qty") or row.get("ord_psbl_qty") or 0))
if qty <= 0:
continue
name = (row.get("prdt_name") or row.get("prdt_name_eng") or code).strip()
self.holdings[code] = {"total_qty": qty, "name": name or code}
except Exception:
pass
def _update_assets(self):
"""자산 정보 업데이트 (KIS API)"""
try:
balance = self.client.get_account_evaluation()
if not balance:
return
# ver1과 동일: watchlist LONG 반영 (단타와 동일 inquire-balance → output2=예수금, output1=종목 리스트)
self._merge_watchlist_holdings_from_balance(balance)
out2 = balance.get("output2")
if isinstance(out2, list) and out2:
out2 = out2[0]
out2 = out2 if isinstance(out2, dict) else {}
ord_psbl = out2.get("ord_psbl_cash") or out2.get("dnca_tot_amt")
self.current_cash = float(str(ord_psbl or 0).replace(",", "").strip()) if ord_psbl is not None else 0
output1_list = balance.get("output1") or []
if isinstance(output1_list, dict):
output1_list = [output1_list]
holdings_value = 0
for code in self.holdings:
evlu = None
for item in output1_list:
if (item.get("pdno") or "").strip() == code:
evlu = float(item.get("evlu_amt", 0) or 0)
break
if evlu is not None:
holdings_value += evlu
else:
price_data = self.client.inquire_price(code)
if price_data:
holdings_value += abs(float(price_data.get("stck_prpr", 0) or 0)) * self.holdings[code]["total_qty"]
self.current_total_asset = self.current_cash + holdings_value
except Exception as e:
logger.error(f"자산 정보 업데이트 실패: {e}")
def _maybe_set_start_day_asset(self):
"""오늘 날짜 기준으로 start_day_asset 설정 (당일 첫 실행 시)"""
state = _load_state()
saved_date = state.get("date", "")
if saved_date == self.today_date and state.get("start_day_asset"):
self.start_day_asset = float(state["start_day_asset"])
return
self.start_day_asset = self.current_total_asset
_save_state({"date": self.today_date, "start_day_asset": self.current_total_asset})
def send_mm(self, msg):
try:
self.mm.send(self.mm_channel, msg)
except Exception as e:
logger.error(f"❌ MM 전송 에러: {e}")
def send_report_1(self):
"""1회차: 오전 브리핑 (13:00)"""
self._update_assets()
self._maybe_set_start_day_asset()
day_pnl = self.current_total_asset - self.start_day_asset
day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
msg = f"""📊 **[늘림목 오전 브리핑 - {dt.now().strftime('%H:%M')}]**
- 당일 시작: {self.start_day_asset:,.0f}
- 현재 자산: {self.current_total_asset:,.0f}
- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
- 보유 종목: {len(self.holdings)}"""
self.send_mm(msg)
logger.info("📊 오전 브리핑 전송 완료")
def send_report_2(self):
"""2회차: 장마감 최종 브리핑 (15:35)"""
self._update_assets()
self._maybe_set_start_day_asset()
day_pnl = self.current_total_asset - self.start_day_asset
day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
cumulative_pnl = self.current_total_asset - self.total_deposit
cumulative_pnl_pct = (cumulative_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 0
today_ymd = dt.now().strftime("%Y%m%d")
today_trades = self.db.get_trades_by_date(today_ymd)
msg = f"""🏁 **[늘림목 장마감 브리핑 - {dt.now().strftime('%H:%M')}]**
━━━━━━━━━━━━━━━━━━━━
📅 **당일 손익**
- 시작: {self.start_day_asset:,.0f}
- 종료: {self.current_total_asset:,.0f}
- 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
💰 **누적 손익 (총 입금액 대비)**
- 총 입금: {self.total_deposit:,.0f}
- 현재 자산: {self.current_total_asset:,.0f}
- 누적 손익: {cumulative_pnl:+,.0f}원 ({cumulative_pnl_pct:+.2f}%)
📊 **거래 현황**
- 오늘 매매: {len(today_trades)}
- 보유 종목: {len(self.holdings)}
- 예수금: {self.current_cash:,.0f}
━━━━━━━━━━━━━━━━━━━━"""
self.send_mm(msg)
logger.info("🏁 장마감 브리핑 전송 완료")
def main():
(h1, m1), (h2, m2) = _get_alert_times()
logger.info(f"늘림목 알림 봇 시작 (하루 2회: {h1:02d}:{m1:02d}, {h2:02d}:{m2:02d})")
sent_1 = False
sent_2 = False
last_date = ""
while True:
try:
now = dt.now()
today = now.strftime("%Y-%m-%d")
if today != last_date:
last_date = today
sent_1 = False
sent_2 = False
if now.hour == h1 and now.minute == m1 and not sent_1:
bot = LongAlertBot()
bot.send_report_1()
sent_1 = True
elif now.hour == h2 and now.minute == m2 and not sent_2:
bot = LongAlertBot()
bot.send_report_2()
sent_2 = True
except KeyboardInterrupt:
logger.info("종료")
break
except Exception as e:
logger.error(f"알림 루프 에러: {e}")
time.sleep(60)
if __name__ == "__main__":
main()