늘림목까지 완성성
This commit is contained in:
267
kis_long_alert.py
Normal file
267
kis_long_alert.py
Normal file
@@ -0,0 +1,267 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user