268 lines
10 KiB
Python
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()
|