From 168e7d016ea9848b7bb2eb81b92722d005d21f24 Mon Sep 17 00:00:00 2001 From: Hwang Date: Sun, 22 Feb 2026 21:42:41 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8A=98=EB=A6=BC=EB=AA=A9=EA=B9=8C=EC=A7=80?= =?UTF-8?q?=20=EC=99=84=EC=84=B1=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursorrules | 8 +- __pycache__/database.cpython-312.pyc | Bin 42846 -> 46414 bytes auto_ai_reporter.py | 263 +++++++ database.py | 76 +- kis_long_alert.py | 267 +++++++ kis_long_ver1.py | 442 ++++++++--- kis_short_ver1.py | 274 +++---- kiwoom_trader_ver2.py | 1045 ++++++++++++++++++++++++++ quant_bot.db | Bin 77824 -> 86016 bytes update_env_simple.py | 11 +- 10 files changed, 2120 insertions(+), 266 deletions(-) create mode 100755 auto_ai_reporter.py create mode 100644 kis_long_alert.py create mode 100644 kiwoom_trader_ver2.py diff --git a/.cursorrules b/.cursorrules index bfd675c..45cdc9c 100644 --- a/.cursorrules +++ b/.cursorrules @@ -17,4 +17,10 @@ # 4. 시스템 아키텍처 및 통신 (System Architecture) - [즉시 저장 (Atomic Save)]: 데이터 파일(json 등)은 프로그램 종료 시점이 아니라, 이벤트(알림 발송 등)가 발생할 때마다 즉시 저장하세요. 재시작 시 기존 데이터를 삭제하지 않고 수정 데이터만 끼워 넣는 방식으로 안정적으로 운영하세요. - [API 요청 규칙]: 모든 API 요청은 `utils/request_handler.py`의 `SafeRequest` 클래스를 상속받아 구현하세요. HTTP 429(Too Many Requests) 에러 발생 시 재시도(Retry) 로직을 반드시 포함하세요. -- [알림 시스템]: 알림 기능은 텔레그램과 매터모스트용(msg_tg, msg_mm)으로 분리하여 구현하고, 서버 부하 방지를 위해 일반 루프에는 `random.sleep(1~3)`을 기본 적용하세요. (실시간 매매 로직 제외) \ No newline at end of file +- [알림 시스템]: 알림 기능은 텔레그램과 매터모스트용(msg_tg, msg_mm)으로 분리하여 구현하고, 서버 부하 방지를 위해 일반 루프에는 `random.sleep(1~3)`을 기본 적용하세요. (실시간 매매 로직 제외) +## [로직 누락 방지 규칙] +- 모든 코드를 작성한 후, 스스로 다음 항목이 포함되었는지 검토하고 대답하세요. +- 1. 손절(Stop-loss) 및 예외 처리 로직이 포함되었는가? +- 2. API 호출 제한(429 Error) 및 슬리피지 고려가 되었는가? +- 3. 사용자가 요청한 기존 로직과 100% 동일한 기능을 수행하는가? +- 만약 하나라도 빠졌다면 코드를 출력하기 전에 스스로 수정하세요. \ No newline at end of file diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index 2a6eb76c1f07ef2c80ffccd040a50aac6ee85f5c..e4fcaa4e79c24a0ca5fd10c6bf164d15449adb60 100644 GIT binary patch delta 4851 zcmaJ^4OCRe6@K&HzTJKM2h0Az%CbCMkX-=97P-MwtK{1P9 zN?Kxwilf2&pjb6((rT$N!p}M(&dm72r<^A&1vIqnpJF*W6o)N=j|?Fo6~`J z=bL-)-1)nA?(Ef%=#Q?`x<6>ODhf)*x6jrEj-JtdC^x>0D5{roQI$xbDro^#GQwQ^ ztvs#$E`f8YTxtub*V2NjldaSUYL{BjxU{u0LE9;-)Jbif)Q*963`dbaVV=Z~b!i1X z=*PilaH(rmf)Q-Q5*gStfxceSGlM)Hz68*>xZ*%>8psUo!h9hS*c^OTm(isK5iLzd zE+l~=6AW~>U;}Bg%PjCvr@%K|8psavcwvE%>WYKDWV&g}Ih|Ccy;bR4Br1HXls9R1Hw~wQZ>)>Qd+$;B?pLc3{z09g zmA?q5Mhc(N<}-lf7Cs*sZlA zoeyj8i=lIAoFf?U9}S86hnicfT-#kW&7L}ULsh+d6J9=T9gfwhFwU{zM&n(0e@-%f zGcGBM&&uK}pkx&nXB8Cij^JYheBiPE;K}FtCHyxBpAYQqw{^|f9eDeA01G{iff@w+hR(ErQhhJZqSHzc;mh&a|78PZJvQ(f) z;NN0Wg=YEIrpd+E&_&R(C`&eTzDGA~m=J+Pai+E_0u$cW#y z$?GUV=9Ob45FSf-faQSY5v%|#c_i?aQ!odZi|D8Tt0GtpSh^uVUjtY}_zy!v3k_XF zDF$$C1nU9oBRF;nKMt@I9MIPQ*bvb%0yajl39u=G&4Ss>;=yWZW9%)INLFMf!UiP7 z*P#>03oBg=?e1N0e7qM%gJ(TttydrNq=W z1(=AG%_jVa+Q3ZnD)1{SP1g7}#ZE;TB}nx)w2j)%RDcgT6Pq_9d~Ia~{`bzf>i;_X zg?x#-!4>Q~8#sNI5B7HkUhBx@(~C+=R;5Rn(GGmkntKYvqO|nD$!<_9TV1-oJe~9m zLy1qK2VZ+qnd}JcJ`{N2V&IpjX9u1<_03N^_+aPZ;Nea_FmO6}vIAEiaNv?vOHd9* z8>SoY9A?>Vl(r)g1c~_MstXpiSO!ZL9|AJQf@WRg7~9;~=o0ak)oDtlSV5#OVZ7Rb zXFeT=KUkgh;0oBqCN;Qc2Ng0qIP<45woyyc5z8}{5zEXW%gj;Zs!>zwh-v1KY38sg zbHtQ8WXc^j%^heOGA;BQ7mu^dw74F5NJUMvP4@CvQg04b~+5w>kyDr=KN^pV@Taif}1L~!POHLc|E&@OVv&#q*= z$V+<}ufnSY7iMb}(P)ROUPk^V+pVmf%AuOjHX2bCkO~mh%7(oU{8!5JWFp|dX>D92 z1lr|Nj!<~H=cvAk5Fp(JSsS;LYgLBpDUQrF*MSvP^cId>!O^=Le7OGv_;5$?@Cmq( z`!2$N5vp4L(GY!2nWeC0^EnrNRF5_F~DtQT18gc)~qx^ z+y?{ict!H$Wb)w?$>bmLv`T5=<<10rb&0;6 ztfHj6IRy-V+1Jh2H8iOqn)f6|HIC$}6P>@btJW0@T zAYjz&-$<7FUa`uAJcdPt~B z#VZq}XJIrUc(hX_QXY*;YH2A^=%R86wCQau?%$l;4%rCei;ika@g;9#ce1StL8_<3 zm$La?u%L=~2*l3Wz>^oEVL~2N@IDIm?~BBVGuXS2k6zo6rvzbzyOCJieSlvW3DQ7U zXRv=)H?JG+2iVDkC z6gtZ8Ep~(h&Y8()!?&XJ-jZ_1%*h_n#jRXlTFl=OZRCkLVK}O`VRhm9LP?KI5pIrU zd|pYx#InOlv3=Pc{lr&b7e(?i5;p^x2=PrW4_q{L^{rBrM;@5r5QjAEr2h)aQjz*P zRg+0id=>bjkC5L28Dnef-PIoPeZp{4l#LQM5O^AhQy#t_J|&Eo2swvsJ+^k^kBvSV z|&CLrTh!c&K#5nloh08Me+Du`U?0 zE*Q2hI$twn&GYN?$2pm~0ClYlZBbDMOYiKS*~4-6t`%2|mP7VK)z8ep#mDFLH=W8k z+H!1>-!R*+nf-kvj;&MTc-{FGe#`QYHF;MIiBbexZ#N~tydKMQ8SBC_kHMh z_-Xen!#_ZAObtI-+_kk*Qi0tcabKnLXy5CNZW?{>OK*CQmVXCo;wShY&3WiUJijHw zdWA3~k0HdV5nmt-=_C=@~rQHJ0=gC7taxuEYHX+% z5wt}b;lH$$p~ro9Z~Kn1N-VOVv6$+XO%Sc_+B#1a`57R_;(H$D)hy{_BqSEMJeob7 z_!;r7sjejpyOuPtNa4fXW__b94!j0FUPjv)+|)CK`{$W`!v1?`G=>Kbyp$uorO1Ow{D6=T3GovmB}uNyRbL-I zszG2nwPH5jb1)(G|Mn8ePdafB|Nh{NOedLTi{YqiJY5~iHfYuNg9 zDV@*wwm#j>qFwm*bFF9`d!8Rf9$#_aGmKT*qHJQjgUp2yC$P8x(?=JorBe|=CZ5Gf z#naT1e{Uk~1-Sm`GPK9{tD|2d<5DoOLmSQDHTCW$*C~AZ*eVAq0i$G;k`nlRC?oHD zCsRTiUz4AiP$@{2inYg!+F4?EoRHTE`J9kn6Y>@zLxc9WG9pMND4mcDLgo;%kdVcMEJfIVoL`}Uc<7{V&@`(YmviiLbkp31*rhjj zGB$R}&E+VWy(_d9EoCkJo^cAQ8_VfAta%{ey!}klI0Z1Y89Pq2KVXh$E!TAV5DPUt zEn?zA3WDJNGKV;VRFvKvQWK=1boy~Epc}b*)_STeL_sw;N2>hg75>U9f3@JR6o$%O k<3tq7#-~o)LWQ{Rg)L|e=1#7R@oG`dO^Q(Qepu>%0LtltJOBUy delta 2317 zcmaJ?du&tZ6~EtieQ*4BUUuTdaYAAzc#vRdGgh>;p*5unk4jAma@LZi`L0`w1V=Xx z5OA7EL|tfMv>a)yw1sH&MJqv)3+rRB2cZ+JYe!mXMc%|Vt=k0c78?4{B9-o(8&mje z*YfZF&hMP>eCM9?+4ub`{L=f}{jSSpm+;E}?ZM>spG>&lQ;P+Yqyv&BwKE~LbHUnW zQ4cd}c*r^;wcCYFa|pZUOjv|t#M16EZKr9w(01D-`iKTo_h?R$kMRZgEz}$dyC@n_ z+CAh;a8Zo$`53!Ic+v0E3PcIsOY!SBW6LnMP(($!RwM$Na|pMrK&eNm!xMwbF0PaOCzEb+{!3P{L&ArgK~z)q=%oBq(Mu@A~>~cP)SOHXB1#UM$McV za`Icqk6b~n=D2zb&qJP<)2+zOW@Ee!xh<#Lk=v0cDUkyWXU^e5?#^)!a!-!CZ_)FS z=jZeSBAaY9t`wPmja( zwF@nIBrICDBHW0FsqX{xL|t`P+k6+j{sZx6;vacyr@gfo>S7;ySB>6lOszY`0{2wj zQ!PzZ+hQJhYLP!?m(N-l^0Rg&<}seT{{Uw{G;VvKMlL*w$s>v$r=6c6coQDma3AZ2 zi49}Q8z`(dHniPgW#h1KQ^@-Zia15EnV_3s0?us$w$pg1V^U_n22a;Xb`IX@I&Jjy z^r+qo6rvMM6MR7MA>WQG14*q>P_&#^H4D$~ZM zz&F@X^iK@SI=cGPsYIefbfz^dc;nzD{xvg}Kc3?3it)5X`{!U8U?Ql#EnHub_}s=hN#Gb363H}Ec5d-x7Q^I9`wPc-4XT!V|4eQW#$5H??$%GIKB6hg&l|I_jj?P{UF!@ zLxa22{EL*vOBw^=|cBy4#mq5kA7WTIB*ump+tr_7nWduAj=4sjg?c#YCG#zSl z+-$=n96Hp>|H9$gp=o>8O6~9HOY6{khtJ3!Uc>Ektp2i!Id(h+(aoi>`e%D?H_zB% z%6pFBJi+e?UPQ>+`n2xu`8m=#jJWh#EC+u--q8PDsMk^Hr=a;r?b0AUfOWoS3y^2s zW?_kx-qV>*YvPtUPSVCYRIDq9$seB>NPOPqwb;`beeMDZ5z%J7p0&n!k5UGYAPplm>DDTGzStZe(>P+iDC*f^^ z_Xz$#P*2tUgV;L+oJ?xIt`c(+6cSiS^AigYR1ri76mmp~EhC_M^#RhLbF%b%V`z>@ zpK-2Ouc 0) + losses = total - wins + win_rate = wins / total * 100 if total > 0 else 0 + + avg_profit = sum(t['profit_rate'] for t in trades) / total + total_pnl = sum(t['realized_pnl'] for t in trades) + + avg_hold = sum(t['hold_minutes'] for t in trades) / total + + return f""" +📊 최근 {total}건 거래 통계 +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:,.0f}원 +- 평균 보유: {avg_hold:.0f}분 +""" + +def analyze_with_ai(trades): + """AI로 거래 분석 및 권장사항""" + if not model: + return "❌ AI 분석 불가 (Gemini API 키 없음)" + + trades_text = "" + for i, t in enumerate(trades, 1): + trades_text += f""" +[거래 {i}] {t['name']} ({t['strategy']}) +- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 +- 매도: {t['sell_price']:,.0f}원 +- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) +- 보유: {t['hold_minutes']}분 +- 사유: {t['sell_reason']} +""" + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +다음은 최근 {len(trades)}건의 거래 내역입니다: + +{trades_text} + +{get_trade_summary(trades)} + +**당신의 임무:** +1. 문제점 3가지 진단 (구체적으로) +2. .env 수정 권장사항 (변수명=값 형식) +3. 예상 효과 + +**출력 형식:** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] +3. [구체적 문제 3] + +## 💡 권장 수정사항 +``` +RSI_OVERHEAT_THRESHOLD=XX +HIGH_PRICE_CHASE_THRESHOLD=X.XX +STOP_LOSS_PCT=-X.XX +... +``` + +## 📈 예상 효과 +- [효과 1] +- [효과 2] + +**간결하고 명확하게 답변하세요.** +""" + + try: + response = model.generate_content(prompt) + return response.text + except Exception as e: + return f"❌ AI 분석 실패: {e}" + +def send_daily_report(): + """13시 정기 보고서""" + print(f"\n{'='*80}") + print(f"🤖 자동 AI 분석 시작: {datetime.datetime.now()}") + print(f"{'='*80}\n") + + trades = get_recent_trades(10) + + if not trades: + print("❌ 거래 내역 없음") + return + + print("🧠 AI 분석 중...") + analysis = analyze_with_ai(trades) + + summary = get_trade_summary(trades) + + message = f"""🤖 **[13시 AI 자동 분석]** + +{summary} + +{analysis} + +--- +💬 명령어 사용법: +- `!env 보기` - 현재 설정 확인 +- `!env RSI_OVERHEAT_THRESHOLD=75` - 설정 변경 +- `!ai 왜 승률이 낮아?` - AI 질문 +""" + + if send_mm(message): + print("✅ 보고서 전송 완료") + else: + print("❌ 전송 실패") + +def main(): + """메인 루프""" + print("🤖 자동 AI 분석 보고서 시작") + print(f"- 보고 시간: 매일 13:00") + print(f"- 분석 대상: 최근 10건 거래") + print(f"- AI 모델: Gemini 2.5 Flash") + print() + + if not model: + print("❌ Gemini API 키가 없습니다.") + return + + reported_today = False + main.last_date = datetime.date.today() + + while True: + now = datetime.datetime.now() + current_date = now.date() + + if main.last_date != current_date: + reported_today = False + print(f"\n📅 날짜 변경: {current_date}") + + main.last_date = current_date + + if now.hour == 13 and now.minute == 0 and not reported_today: + send_daily_report() + reported_today = True + time.sleep(60) + + time.sleep(60) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\n👋 종료") + except Exception as e: + print(f"\n\n❌ 에러: {e}") + import traceback + traceback.print_exc() diff --git a/database.py b/database.py index 14e69a8..6381733 100644 --- a/database.py +++ b/database.py @@ -147,7 +147,21 @@ class TradeDB: ) """) - # 5. 매수 후보군 테이블 (target_universe 대체) + # 5. 매수 체결 이력 (일일 한도용 - '산 시점' 날짜 기준 누적) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS buy_execution_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT NOT NULL, + strategy TEXT NOT NULL, + buy_date TEXT NOT NULL, -- YYYY-MM-DD (산 날짜 = 한도 기준일) + executed_at TEXT NOT NULL, -- 체결 시각 + amount REAL NOT NULL, -- 매수 금액 (주가×수량) + qty INTEGER NOT NULL + ) + """) + + # 6. 매수 후보군 테이블 (target_universe 대체) self.conn.execute(""" CREATE TABLE IF NOT EXISTS target_candidates ( code TEXT PRIMARY KEY, -- 종목코드 @@ -159,7 +173,7 @@ class TradeDB: ) """) - # 6. env 설정 전용 테이블 (관리자용, INSERT만 / 최신 1건 = 현재 설정, 키당 컬럼) + # 7. env 설정 전용 테이블 (관리자용, INSERT만 / 최신 1건 = 현재 설정, 키당 컬럼) cols = ", ".join([f'"{k}" TEXT' for k in ENV_CONFIG_KEYS]) self.conn.execute(f""" CREATE TABLE IF NOT EXISTS env_config ( @@ -306,15 +320,25 @@ class TradeDB: logger.error(f"❌ upsert_trade 실패 ({code}): {e}") return False - def get_active_trades(self): + def get_active_trades(self, strategy_prefix: Optional[str] = None): """ 활성 트레이딩 목록 조회 (봇 재시작 시 사용) + Args: + strategy_prefix: None이면 전부, 'LONG'이면 strategy LIKE 'LONG%'만, 'SHORT'면 'SHORT%'만 + (늘림목/단타 섞임 방지) + Returns: {종목코드: {trade_info}} 형태의 딕셔너리 """ try: - cursor = self.conn.execute("SELECT * FROM active_trades") + if strategy_prefix: + cursor = self.conn.execute( + "SELECT * FROM active_trades WHERE strategy LIKE ?", + (strategy_prefix.strip().upper() + "%",) + ) + else: + cursor = self.conn.execute("SELECT * FROM active_trades") rows = cursor.fetchall() # 기존 JSON 포맷과 호환되도록 딕셔너리 변환 @@ -464,6 +488,50 @@ class TradeDB: logger.error(f"❌ 삭제 실패 ({code}): {e}") return False + def insert_buy_execution( + self, + code: str, + name: str, + strategy: str, + amount: float, + qty: int, + ): + """ + 매수 체결 이력 저장 (일일 한도용). '하루' = 산 날짜(buy_date) 기준. + """ + now = datetime.datetime.now() + buy_date = now.strftime("%Y-%m-%d") + executed_at = now.strftime("%Y-%m-%d %H:%M:%S") + try: + with self.conn: + self.conn.execute(""" + INSERT INTO buy_execution_log (code, name, strategy, buy_date, executed_at, amount, qty) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (code, name, strategy, buy_date, executed_at, amount, qty)) + return True + except Exception as e: + logger.error(f"❌ insert_buy_execution 실패 ({code}): {e}") + return False + + def get_daily_buy_amount(self, date_str: str, strategy_prefix: str = "LONG") -> Tuple[float, int]: + """ + 해당 날짜(산 시점 기준)에 strategy_prefix에 해당하는 매수 누적 금액·건수. + date_str: YYYY-MM-DD + Returns: + (누적 금액, 건수) + """ + try: + cursor = self.conn.execute(""" + SELECT COALESCE(SUM(amount), 0), COUNT(*) + FROM buy_execution_log + WHERE buy_date = ? AND strategy LIKE ? + """, (date_str, strategy_prefix.strip().upper() + "%")) + row = cursor.fetchone() + return (float(row[0]), int(row[1])) + except Exception as e: + logger.error(f"❌ get_daily_buy_amount 실패: {e}") + return (0.0, 0) + # ============================================================ # [보강] 주문·체결 이력 (kt00007 / ka10076) # ============================================================ diff --git a/kis_long_alert.py b/kis_long_alert.py new file mode 100644 index 0000000..408eb61 --- /dev/null +++ b/kis_long_alert.py @@ -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() diff --git a/kis_long_ver1.py b/kis_long_ver1.py index 24e2b7f..f83c6a1 100644 --- a/kis_long_ver1.py +++ b/kis_long_ver1.py @@ -8,7 +8,7 @@ KIS Long Trading Bot Ver1 - 늘림목 전략용 한투 API 트레이딩 시스 import os import json -import time +import ㅁㅁ import random import logging import datetime @@ -154,55 +154,51 @@ def _save_kis_token_cache(access_token, access_token_token_expired, mock): class KISClientWithOrder: - """주문 기능이 추가된 KIS 클라이언트""" + """주문 기능이 추가된 KIS 클라이언트 (env 키는 단타 봇과 동일: KIS_APP_KEY_MOCK/REAL, KIS_ACCOUNT_NO_MOCK/REAL)""" def __init__(self, mock=None): - # DB에서 환경변수 읽기 - self.app_key = get_env_from_db("KIS_APP_KEY", "").strip() - self.app_secret = get_env_from_db("KIS_APP_SECRET", "").strip() - # 모의 여부 결정 + # 모의 여부 결정 (단타 봇과 동일) if mock is not None: use_mock = mock else: use_mock = get_env_bool("KIS_MOCK", True) - - # 계좌번호: 모의/실전 분리 (사용자가 8자리로 직접 입력) + + # 앱키/시크릿: 모의·실전 분리 (단타와 동일 키 사용 → DB 한 세트로 둘 다 실행 가능) if use_mock: - raw_no_mock = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "").strip() - raw_code_mock = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "").strip() - if raw_no_mock: - raw_no = raw_no_mock - if raw_code_mock: - raw_code = raw_code_mock - else: - raw_code = "01" - else: - raw_no = get_env_from_db("KIS_ACCOUNT_NO", "").strip() - raw_code = get_env_from_db("KIS_ACCOUNT_CODE", "01").strip() - if not raw_code: - raw_code = "01" + self.app_key = (get_env_from_db("KIS_APP_KEY_MOCK", "") or get_env_from_db("KIS_APP_KEY", "")).strip() + self.app_secret = (get_env_from_db("KIS_APP_SECRET_MOCK", "") or get_env_from_db("KIS_APP_SECRET", "")).strip() + if not self.app_key or not self.app_secret: + logger.error("❌ 모의투자용 APP KEY/SECRET이 DB에 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 설정 필요.") + raise ValueError("모의투자 KIS_APP_KEY_MOCK / KIS_APP_SECRET_MOCK 미설정") else: - raw_no = get_env_from_db("KIS_ACCOUNT_NO", "").strip() - raw_code = get_env_from_db("KIS_ACCOUNT_CODE", "01").strip() + self.app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or get_env_from_db("KIS_APP_KEY", "")).strip() + self.app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or get_env_from_db("KIS_APP_SECRET", "")).strip() + + # 계좌번호: 모의/실전 분리 (단타와 동일) + if use_mock: + raw_no = (get_env_from_db("KIS_ACCOUNT_NO_MOCK", "") or get_env_from_db("KIS_ACCOUNT_NO", "")).strip() + raw_code = (get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "") or get_env_from_db("KIS_ACCOUNT_CODE", "01")).strip() if not raw_code: raw_code = "01" - - # DB 값 그대로 사용 (10자리면 앞8/뒤2만 분리) + else: + raw_no = (get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or get_env_from_db("KIS_ACCOUNT_NO", "")).strip() + raw_code = (get_env_from_db("KIS_ACCOUNT_CODE_REAL", "") or get_env_from_db("KIS_ACCOUNT_CODE", "01")).strip() + if not raw_code: + raw_code = "01" + + # 10자리면 앞 8 / 뒤 2 분리 if len(raw_no) >= 10: self.acc_no = raw_no[:8] self.acc_code = raw_no[8:10] else: self.acc_no = raw_no - if len(raw_code) >= 2: - self.acc_code = raw_code[:2] - else: - self.acc_code = "01" + self.acc_code = raw_code[:2] if len(raw_code) >= 2 else "01" if len(self.acc_no) != 8: logger.warning("⚠️ 계좌번호 CANO 8자리 아님: '%s'(%s자리). DB 확인.", self.acc_no, len(self.acc_no)) - + if len(self.acc_no) != 8 or len(self.acc_code) != 2: logger.error( "❌ 계좌번호 형식 오류: CANO=%s(%s자리), ACNT_PRDT_CD=%s(%s자리) → OPSQ2000 발생. " - "모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO/KIS_ACCOUNT_CODE 확인.", + "모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO_REAL 또는 KIS_ACCOUNT_NO 확인.", self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code) ) else: @@ -219,7 +215,8 @@ class KISClientWithOrder: def _auth(self): """접근 토큰 발급""" if not self.app_key or not self.app_secret: - raise ValueError("KIS_APP_KEY, KIS_APP_SECRET 설정 필요") + hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)" + raise ValueError(f"한투 앱키 미설정: {hint}") cached = _load_kis_token_cache(self.mock) if cached: @@ -341,37 +338,51 @@ class KISClientWithOrder: return None def get_account_evaluation(self): - """계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R.""" + """계좌 잔고 조회 (단타와 동일: inquire-balance, VTTC8434R/TTTC8434R).""" if self.mock is True: - tr_id = "VTTC8494R" + tr_id = "VTTC8434R" else: - tr_id = "TTTC8494R" + tr_id = "TTTC8434R" + params = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "01", + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "00", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + } try: r = self._get( - "/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl", + "/uapi/domestic-stock/v1/trading/inquire-balance", tr_id, - { - "CANO": self.acc_no, - "ACNT_PRDT_CD": self.acc_code, - "AFHR_FLPR_YN": "N", - "OFL_YN": "", - "INQR_DVSN": "01", - "UNPR_DVSN": "01", - "FUND_STTL_ICLD_YN": "N", - "FNCG_AMT_AUTO_RDPT_YN": "N", - "PRCS_DVSN": "01", - "CTX_AREA_FK100": "", - "CTX_AREA_NK100": "", - }, + params, ) if r.status_code != 200: + logger.warning( + f"💵 [예수금] 잔고 API HTTP 오류: status={r.status_code}, body={getattr(r, 'text', '')[:200]} | TR={tr_id}, CANO={self.acc_no}, 모의={self.mock}" + ) return None j = r.json() if j.get("rt_cd") != "0": + msg_cd = j.get("msg_cd", "") + msg1 = (j.get("msg1") or "")[:200] + logger.warning( + f"💵 [예수금] 잔고 API 응답 오류: rt_cd={j.get('rt_cd')}, msg_cd={msg_cd}, msg1={msg1} | " + f"TR={tr_id}, CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}" + ) + if "OPSQ2000" in str(msg_cd) or "INVALID" in msg1.upper(): + logger.warning( + "💵 [예수금] 계좌번호 검증 실패일 수 있음. 모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK 확인." + ) return None return j except Exception as e: - logger.error(f"계좌 평가 조회 실패: {e}") + logger.error(f"💵 [예수금] 잔고 조회 실패: {e} | CANO={self.acc_no}, 모의={self.mock}") return None def inquire_daily_itemchartprice(self, stock_code, start_ymd, end_ymd, period="D", adj="1"): @@ -777,9 +788,26 @@ class MattermostBot: # 늘림목 트레이딩 봇 # ============================================================ class LongTradingBot: - """늘림목 전략 트레이딩 봇 - 장기 보유 + 분할 매수""" + """늘림목 전략 트레이딩 봇 - 장기 보유 + 분할 매수 + (단타 봇과 동일 env 키 사용: KIS_MOCK, KIS_APP_KEY_MOCK/REAL, KIS_ACCOUNT_NO_MOCK/REAL, TOTAL_DEPOSIT, MAX_STOCKS 등) + """ def __init__(self): self.db = db + # 자산·리포트·루프용 변수 (단타 봇과 동일 이름) + self.today_date = dt.now().strftime("%Y-%m-%d") + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.ai_report_sent = False + self.start_day_asset = 0 + self.current_cash = 0 + self.current_total_asset = 0 + self.d2_excc_amt = 0 # D+2 예수금 (전일 정산 수령 예정, 매매 가능 판단 참고) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + self.max_stocks = get_env_int("MAX_STOCKS", 5) + # 일일 매수 한도 (0 = 총자산의 30% 자동, 양수면 해당 금액 한도). 누적은 DB buy_execution_log(산 날짜 기준) + self.daily_buy_limit = get_env_float("DAILY_BUY_LIMIT", 0) + self.client = KISClientWithOrder() # Mattermost 초기화 @@ -801,11 +829,14 @@ class LongTradingBot: # 손절/익절 self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.30) - self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.50) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.10) + # 매매 루프 체크 간격 (늘림목은 주가 변동성 적음 → 5~10분 권장) + self.loop_interval_min_low = get_env_float("LOOP_INTERVAL_MIN_LOW", 5) + self.loop_interval_min_high = get_env_float("LOOP_INTERVAL_MIN_HIGH", 10) - # DB에서 활성 트레이드 로드 + # DB에서 활성 트레이드 로드 (늘림목만: LONG_INITIAL, LONG_DCA - 단타와 섞이지 않도록) self.holdings = {} - active_trades = self.db.get_active_trades() + active_trades = self.db.get_active_trades(strategy_prefix="LONG") for code, trade in active_trades.items(): # 분할 매수 정보 복원 (간단화) self.holdings[code] = { @@ -821,13 +852,14 @@ class LongTradingBot: self.watchlist_path = SCRIPT_DIR / "long_term_watchlist.json" self.watchlist = self._load_watchlist() - # 초기 자산 조회 - self._update_assets() + # 초기 자산 조회는 하지 않음 → 루프 진입 시 "예수금 될 때까지 3초 간격 재시도"로만 수행 (중복/EGW00201 방지) # 비동기 태스크 관리 self._report_task = None self._asset_task = None - + # 잔고 API 초당 제한(EGW00201) 방지: 마지막 조회 시각 + self._last_balance_fetch_time = 0.0 + def _load_watchlist(self): """관심 종목 리스트 로드""" if not self.watchlist_path.exists(): @@ -842,26 +874,92 @@ class LongTradingBot: logger.error(f"관심 종목 로드 실패: {e}") return [] + def _merge_watchlist_holdings_from_balance(self, balance): + """ + watchlist에 strategy "LONG" 또는 use "dca"로 넣어둔 종목 중, + 계좌에는 보유 중인데 DB(봇)에는 없는 종목을 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("") + if not watchlist_dca_codes: + return + 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 + evlu_amt = float(row.get("evlu_amt") or 0) + evlu_pfls = float(row.get("evlu_pfls_amt") or 0) + pchs_avg = row.get("pchs_avg_pric") + if pchs_avg is not None and str(pchs_avg).strip() != "": + avg_price = abs(float(str(pchs_avg).replace(",", "").strip())) + else: + # 매입금액 = 평가금액 - 평가손익 → 평단 = 매입금액/수량 + cost = evlu_amt - evlu_pfls + avg_price = cost / qty if qty else 0 + if avg_price <= 0: + continue + name = (row.get("prdt_name") or row.get("prdt_name_eng") or code).strip() + self.holdings[code] = { + "buy_prices": [avg_price], + "qtys": [qty], + "total_qty": qty, + "avg_price": avg_price, + "first_buy_date": dt.now().strftime("%Y-%m-%d %H:%M:%S"), + "name": name or code, + } + logger.info( + f"📂 [늘림목] watchlist LONG 반영: {name} ({code}) {qty}주 평단 {avg_price:,.0f}원 (계좌 보유)" + ) + except Exception as e: + logger.debug(f"watchlist 병합 스킵 {code}: {e}") + def _update_assets(self): - """자산 정보 업데이트""" + """자산 정보 업데이트 (단타와 동일: inquire-balance 응답에서 output2로 예수금)""" try: balance = self.client.get_account_evaluation() - if balance: - output1 = balance.get("output1", {}) - self.current_cash = float(output1.get("dnca_tot_amt", 0)) - # 보유 종목 평가액 계산 - holdings_value = 0 - for code, holding in self.holdings.items(): + if not balance: + return + # watchlist에 LONG/dca로 넣어둔 계좌 보유 종목을 holdings에 합침 + self._merge_watchlist_holdings_from_balance(balance) + # 단타와 동일: output2=예수금 요약 + 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") + if ord_psbl is not None and str(ord_psbl).strip() != "": + self.current_cash = float(str(ord_psbl).replace(",", "").strip()) + # 보유 종목 평가액 (output1=종목별 잔고 리스트에서 evlu_amt 사용, 없으면 현재가 조회) + output1_list = balance.get("output1") or [] + if isinstance(output1_list, dict): + output1_list = [output1_list] + holdings_value = 0 + for code, holding in self.holdings.items(): + for item in output1_list: + if (item.get("pdno") or "").strip() == code: + holdings_value += float(item.get("evlu_amt", 0) or 0) + break + else: price_data = self.client.inquire_price(code) if price_data: - current_price = abs(float(price_data.get("stck_prpr", 0))) - holdings_value += current_price * holding["total_qty"] - - self.current_total_asset = self.current_cash + holdings_value - - # 오늘 첫 실행 시 시작 자산 저장 - if self.start_day_asset == 0: - self.start_day_asset = self.current_total_asset + holdings_value += abs(float(price_data.get("stck_prpr", 0) or 0)) * holding["total_qty"] + self.current_total_asset = self.current_cash + holdings_value + if self.start_day_asset == 0: + self.start_day_asset = self.current_total_asset + self._last_balance_fetch_time = time.time() except Exception as e: logger.error(f"자산 정보 업데이트 실패: {e}") @@ -873,39 +971,53 @@ class LongTradingBot: """ try: balance = self.client.get_account_evaluation() - if balance: - output1 = balance.get("output1", {}) - new_cash = float(output1.get("dnca_tot_amt", 0)) - - # 예수금 업데이트 (0원이 아닐 때만, 또는 초기화 시) - if new_cash > 0 or self.current_cash == 0: - self.current_cash = new_cash - - # 보유 종목 평가액 계산 (빠른 버전: 로컬 holdings 사용) - holdings_value = 0 - for code, holding in self.holdings.items(): - # output2에서 보유 종목 정보 확인 (더 빠름) - output2 = balance.get("output2", []) - for item in output2: - if item.get("pdno", "").strip() == code: - # API에서 받은 평가액 사용 - evlu_amt = float(item.get("evlu_amt", 0)) # 평가금액 - holdings_value += evlu_amt - break - else: - # output2에 없으면 로컬 계산 (fallback) - price_data = self.client.inquire_price(code) - if price_data: - current_price = abs(float(price_data.get("stck_prpr", 0))) - holdings_value += current_price * holding["total_qty"] - - # 총자산 계산 (손익 반영) - self.current_total_asset = self.current_cash + holdings_value - if profit_val != 0: - self.current_total_asset += profit_val - - logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원") - return True + if not balance: + logger.warning("💵 [예수금] 잔고 API 응답 없음 → 예수금 갱신 스킵 (TR=VTTC8434R/TTTC8434R 확인)") + return False + self._merge_watchlist_holdings_from_balance(balance) + # 단타와 동일: output2=예수금 요약, output1=종목별 잔고 리스트 + def _cash_block(obj): + if not obj: + return {} + if isinstance(obj, list) and obj: + return obj[0] + return obj if isinstance(obj, dict) else {} + def _parse_amt(v): + if v is None or str(v).strip() == "": + return None + return float(str(v).replace(",", "").strip()) + out2 = _cash_block(balance.get("output2")) + ord_psbl_val = _parse_amt(out2.get("ord_psbl_cash")) + dnca_tot_val = _parse_amt(out2.get("dnca_tot_amt")) or 0 + new_cash = ord_psbl_val if ord_psbl_val is not None else dnca_tot_val + if new_cash > 0 or self.current_cash == 0: + self.current_cash = new_cash + prvs_rcdl = _parse_amt(out2.get("prvs_rcdl_excc_amt")) + self.d2_excc_amt = prvs_rcdl if prvs_rcdl is not None else 0 + + # 보유 종목 평가액: inquire-balance는 output1이 종목별 잔고(리스트) + holdings_value = 0 + output1_list = balance.get("output1") or [] + if isinstance(output1_list, dict): + output1_list = [output1_list] + for code, holding in self.holdings.items(): + for item in output1_list: + if (item.get("pdno") or "").strip() == code: + holdings_value += float(item.get("evlu_amt", 0) or 0) + break + else: + price_data = self.client.inquire_price(code) + if price_data: + holdings_value += abs(float(price_data.get("stck_prpr", 0) or 0)) * holding["total_qty"] + + self.current_total_asset = self.current_cash + holdings_value + if profit_val != 0: + self.current_total_asset += profit_val + if self.start_day_asset == 0: + self.start_day_asset = self.current_total_asset + self._last_balance_fetch_time = time.time() + logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원") + return True except Exception as e: logger.error(f"❌ 경량 갱신 실패: {e}") return False @@ -913,6 +1025,20 @@ class LongTradingBot: def _update_cash_only(self): """예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)""" return self._update_account_light(profit_val=0) + + def _ensure_account_balance(self, profit_val=0, min_interval_sec=0): + """예수금 조회 성공할 때까지 재시도. min_interval_sec>0이면 그 초 미만일 때 캐시 사용(EGW00201 방지). 매수 직전에는 0으로 호출해 항상 최신 조회(단타와 공유 예수금).""" + if min_interval_sec > 0: + now = time.time() + if now - getattr(self, "_last_balance_fetch_time", 0) < min_interval_sec: + if getattr(self, "_last_balance_fetch_time", 0) > 0: + return True + retry_interval_sec = 3 + while True: + if self._update_account_light(profit_val=profit_val): + return True + logger.warning(f"💵 [예수금] 조회 실패 → {retry_interval_sec}초 후 재시도 (성공할 때까지)") + time.sleep(retry_interval_sec) def _seconds_until_next_5min(self): """다음 5분 정각까지 남은 초 계산""" @@ -1309,7 +1435,7 @@ DCA_INTERVALS=X,-X.XX,-X.XX # 외국인/기관 점수 추가 score += investor_score - return { + analysis_result = { "code": code, "name": name, "current_price": current_price, @@ -1332,8 +1458,8 @@ DCA_INTERVALS=X,-X.XX,-X.XX ), } - # 필터링 로그 출력 - if not analysis["is_buyable"]: + # 필터링 로그 출력 (return 전에 실행되도록 수정) + if not analysis_result["is_buyable"]: reasons = [] if per and per > self.max_per: reasons.append(f"PER {per:.1f} > {self.max_per}") @@ -1345,12 +1471,10 @@ DCA_INTERVALS=X,-X.XX,-X.XX reasons.append(f"점수 {score:.1f} < 60") if investor_score < -10: reasons.append(f"수급 점수 {investor_score} < -10") - if reasons: logger.info(f"🔍 [Pass-밸류] {name} {code}: {', '.join(reasons)}") - return { - } + return analysis_result except Exception as e: logger.error(f"종목 분석 실패({code}): {e}") return None @@ -1371,15 +1495,14 @@ DCA_INTERVALS=X,-X.XX,-X.XX # 평단가 대비 하락률 계산 drop_pct = (current_price - avg_price) / avg_price - # 분할 매수 구간 확인 + # 분할 매수 구간 확인 (구간별 1회만 매수 - i번째 구간은 len(buy_prices) > i 이면 이미 매수 완료) + buy_prices = holding.get("buy_prices", []) for i, interval in enumerate(self.dca_intervals): if drop_pct <= interval: - # 이미 이 구간에서 매수했는지 확인 - buy_prices = holding.get("buy_prices", []) - if buy_prices and min(buy_prices) <= current_price * 1.02: # 2% 오차 범위 내 - continue # 이미 매수한 구간 + # i번째 DCA 구간 이미 매수했는지 확인 (매수 횟수로 구간 추적) + if len(buy_prices) > i: + continue # 이미 이 구간 매수 완료 - # 분할 매수 실행 amount = self.dca_amounts[i] if i < len(self.dca_amounts) else self.dca_amounts[-1] qty = int(amount / current_price) if qty > 0: @@ -1438,6 +1561,51 @@ DCA_INTERVALS=X,-X.XX,-X.XX return sell_signals + def _check_daily_limits(self, amount): + """일일 한도 체크 - 매수 전 반드시 호출. '하루' = 산 날짜(buy_date) 기준, DB에서 조회.""" + today = dt.now().strftime("%Y-%m-%d") + if today != self.today_date: + self.today_date = today + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + + # 오늘(산 시점 날짜) 매수 누적은 DB에서 조회 (재시작해도 유지) + daily_buy_amount, daily_buy_count = self.db.get_daily_buy_amount(today, "LONG") + + if len(self.holdings) >= self.max_stocks: + logger.info(f"🚫 최대 종목 수 도달: {len(self.holdings)}/{self.max_stocks}개") + return False, "max_stocks" + + daily_limit = ( + self.daily_buy_limit + if self.daily_buy_limit > 0 + else self.current_total_asset * 0.30 + ) + if daily_buy_amount + amount > daily_limit: + remain = daily_limit - daily_buy_amount + logger.info( + f"🚫 일일 매수 한도 도달: " + f"누적 {daily_buy_amount:,.0f}원 / 한도 {daily_limit:,.0f}원 (잔여 {remain:,.0f}원)" + ) + self.send_mm( + f"⛔ **일일 매수 한도 도달**\n" + f"- 오늘 매수: {daily_buy_amount:,.0f}원\n" + f"- 한도: {daily_limit:,.0f}원\n" + f"- 오늘 추가 매수 중단 (매도/손절 감시는 계속)" + ) + return False, "daily_limit" + return True, "ok" + + def _after_buy(self, code, amount, name, strategy, qty): + """매수 성공 후 DB에 체결 이력 저장 (산 시점 날짜 기준 → 재시작해도 한도 유지)""" + self.db.insert_buy_execution(code=code, name=name, strategy=strategy, amount=amount, qty=qty) + today = dt.now().strftime("%Y-%m-%d") + daily_buy_amount, daily_buy_count = self.db.get_daily_buy_amount(today, "LONG") + logger.info( + f"📊 일일 매수 현황: {daily_buy_count}건 / {daily_buy_amount:,.0f}원 (오늘, DB 기준)" + ) + def execute_buy(self, signal, is_dca=False): """매수 실행""" code = signal["code"] @@ -1445,13 +1613,21 @@ DCA_INTERVALS=X,-X.XX,-X.XX price = signal["price"] qty = signal["qty"] - # 🔥 매수 직전 예수금 실시간 확인 (30분마다 업데이트된 값이 부정확할 수 있음) - if not self._update_account_light(profit_val=0): - logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵") - return False + # 🔥 매수 직전 예수금 실시간 조회 (단타와 같이 돌릴 때 공유 예수금 반영, 캐시 없이 항상 조회) + self._ensure_account_balance(profit_val=0, min_interval_sec=0) # 예수금 부족 체크 (수수료 포함 여유분 5% 고려) required_amount = price * qty * 1.05 # 수수료 포함 + order_amount = int(price * qty) + # 1회 주문 최소 10만 원 (수수료 비율 감안). 0원이면 가격/수량 확인 필요 + if order_amount < 100_000: + logger.warning( + f"⚠️ [{name}] 1회 주문 최소 10만 원 미만: {order_amount:,}원 (price={price:,.0f}, qty={qty}) -> 매수 스킵" + ) + return False + ok, reason = self._check_daily_limits(order_amount) + if not ok: + return False if self.current_cash < required_amount: logger.warning( f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / " @@ -1498,6 +1674,13 @@ DCA_INTERVALS=X,-X.XX,-X.XX "buy_date": holding["first_buy_date"], }) + self._after_buy( + code, + int(price * qty), + name=name, + strategy="LONG_DCA" if is_dca else "LONG_INITIAL", + qty=qty, + ) action = "늘림목 매수" if is_dca else "초기 매수" logger.info(f"💰 [{action}] {name} ({code}): {price:,.0f}원 × {qty}주 (평단: {holding['avg_price']:,.0f}원)") return True @@ -1567,7 +1750,11 @@ DCA_INTERVALS=X,-X.XX,-X.XX def _sync_trading_loop(self): """동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리""" logger.info("📈 매매 루프 시작 (동기 모드)") - + # 최초 예수금 조회: 성공할 때까지 3초 간격 재시도 (매매는 그 다음 5~10분 간격) + self._ensure_account_balance(profit_val=0) + logger.info( + f"💵 예수금 조회 완료 → 매매 루프 진입 | 예수금(주문가능): {self.current_cash:,.0f}원 | D+2: {self.d2_excc_amt:,.0f}원" + ) while True: try: now = dt.now() @@ -1645,8 +1832,13 @@ DCA_INTERVALS=X,-X.XX,-X.XX time.sleep(random.uniform(2, 4)) break # 한 번에 하나씩만 - # 대기 - time.sleep(random.uniform(10, 15)) + # 대기 (늘림목은 변동성 적음 → 5~10분 간격, env: LOOP_INTERVAL_MIN_LOW / LOOP_INTERVAL_MIN_HIGH) + wait_sec = random.uniform( + max(60, self.loop_interval_min_low * 60), + max(60, self.loop_interval_min_high * 60), + ) + logger.info(f"⏳ 다음 체크까지 {wait_sec/60:.1f}분 대기 (로그 멈춤 아님)") + time.sleep(wait_sec) except KeyboardInterrupt: logger.info("⏹ 봇 종료") @@ -1660,7 +1852,7 @@ DCA_INTERVALS=X,-X.XX,-X.XX logger.error(f"❌ 루프 에러: {e}") import traceback logger.error(traceback.format_exc()) - time.sleep(10) + time.sleep(60) if __name__ == "__main__": diff --git a/kis_short_ver1.py b/kis_short_ver1.py index e0bab0a..665584a 100644 --- a/kis_short_ver1.py +++ b/kis_short_ver1.py @@ -42,7 +42,7 @@ LOG_GREEN = "\033[92m" # 통과 LOG_CYAN = "\033[96m" # 강조 LOG_RESET = "\033[0m" -# DB 초기화 +# DB 초기화 (스크립트所在 디렉터리 기준 경로) SCRIPT_DIR = Path(__file__).resolve().parent db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) @@ -1162,12 +1162,11 @@ class KISClient: exclude_spec_etn_leverage: bool, ) -> list: """ - 거래량순위 API 연속 조회 (tr_cont)로 limit건까지 수집. - API가 한 번에 20~30건만 주므로, tr_cont='M'이면 다음 페이지 요청 반복. + 거래량순위 API 1회 조회 (한투 volume-rank API는 다음 페이지 tr_cont 미지원 → 1회만 호출). """ path = "/uapi/domestic-stock/v1/quotations/volume-rank" tr_id = "FHPST01710000" - base = { + params = { "FID_COND_MRKT_DIV_CODE": market, "FID_COND_SCR_DIV_CODE": "20171", "FID_INPUT_ISCD": "0000", @@ -1180,68 +1179,22 @@ class KISClient: "FID_VOL_CNT": "0", "FID_INPUT_DATE_1": "", } - accumulated = [] - tr_cont = "" - max_pages = 20 # 20페이지 이상이면 중단 (과부하 방지) - page = 0 try: - while len(accumulated) < limit and page < max_pages: - params = {**base} - time.sleep(0.5) - # 연속 조회 시 tr_cont는 요청 헤더로 전달 (한투 문서: Request Header tr_cont=N) - r = self._get(path, tr_id, params, tr_cont=tr_cont if tr_cont else None) - if r.status_code != 200: - break - j = r.json() - if j.get("rt_cd") != "0": - break - output = j.get("output", []) - if exclude_spec_etn_leverage: - output = self._filter_rank_by_valid_stock(output) - # 2차 이상 수신 시: 이번 output이 이미 누적된 종목과 완전 동일하면 서버가 같은 페이지를 반복 준 것 → 중복 누적·추가 요청 중단 - def _codes_from_list(lst): - s = set() - for item in lst: - c = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() - if c: - s.add(c) - return s - if page >= 1 and output: - already = _codes_from_list(accumulated) - this_codes = _codes_from_list(output) - if this_codes and this_codes <= already: - logger.info(f" 📡 [순위API] 2차 수신 {len(output)}건은 1차와 동일(중복) → 연속조회 중단 (API가 다음 페이지 미지원 또는 동일 데이터 반환)") - break - accumulated.extend(output) - # 연속 조회: tr_cont는 HTTP 응답 헤더에 있음 (한투 문서). 소문자/대문자 모두 확인 - tr_cont_resp = "" - for k, v in (r.headers or {}).items(): - if k.strip().lower() == "tr_cont" and v: - tr_cont_resp = v.strip() if isinstance(v, str) else str(v) - break - if not tr_cont_resp: - tr_cont_resp = (r.headers.get("tr_cont") or r.headers.get("TR_CONT") or "").strip() - if isinstance(tr_cont_resp, str): - tr_cont_resp = tr_cont_resp.strip() - # 페이지네이션 동작 확인용 INFO 로그 (기본 로그 레벨에서 보이도록) - header_keys = list((r.headers or {}).keys()) - logger.info(f" 📡 [순위API] 1차 수신 {len(output)}건, 누적 {len(accumulated)}건 | 응답 tr_cont='{tr_cont_resp}' | 헤더키: {header_keys}") - # tr_cont=M이면 다음 페이지 있음. 또는 첫 페이지에서 누적이 limit 미만이면 한 번 더 시도 (서버가 tr_cont 없이도 다음 페이지 지원하는 경우 대비) - if tr_cont_resp == "M": - tr_cont = "N" - page += 1 - logger.info(f" 📡 [연속조회] tr_cont=M → tr_cont=N으로 다음 페이지 요청 (페이지 {page})") - elif page == 0 and len(output) > 0 and len(accumulated) < limit: - tr_cont = "N" - page += 1 - logger.info(f" 📡 [연속조회] 누적 {len(accumulated)}건 < {limit}건 → tr_cont=N으로 다음 페이지 1회 시도") - else: - break - time.sleep(random.uniform(0.8, 1.5)) - return accumulated[:limit] + time.sleep(0.5) + r = self._get(path, tr_id, params, tr_cont=None) + if r.status_code != 200: + return [] + j = r.json() + if j.get("rt_cd") != "0": + return [] + output = j.get("output", []) + if exclude_spec_etn_leverage: + output = self._filter_rank_by_valid_stock(output) + logger.info(f" 📡 [순위API] 수신 {len(output)}건 (다음페이지 미지원 → 1회만 호출)") + return output[:limit] except Exception as e: - logger.debug(f"거래량순위 연속 조회 실패: {e}") - return accumulated[:limit] + logger.debug(f"거래량순위 조회 실패: {e}") + return [] def get_volume_rank( self, @@ -1250,7 +1203,7 @@ class KISClient: exclude_spec_etn_leverage: bool = True, ): """ - 거래량순위 조회 [v1_국내주식-047] (연속 조회로 limit건까지 수집) + 거래량순위 조회 [v1_국내주식-047] (1회 호출, API가 반환한 건수만큼 수집) """ try: output = self._fetch_volume_rank_paged( @@ -1530,9 +1483,9 @@ class ShortTradingBot: self.d2_excc_amt = 0 # D+2 예수금 (output2 prvs_rcdl_excc_amt) self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) - # DB에서 활성 트레이드 로드 + # DB에서 활성 트레이드 로드 (단타만: SHORT_% - 늘림목과 섞이지 않도록) self.holdings = {} - active_trades = self.db.get_active_trades() + active_trades = self.db.get_active_trades(strategy_prefix="SHORT") for code, trade in active_trades.items(): self.holdings[code] = { "buy_price": trade.get("avg_buy_price", 0), @@ -1681,6 +1634,37 @@ class ShortTradingBot: except Exception as e: logger.warning(f" ⚠️ [거래대금순위] 수집 실패: {e}") + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 4-2. 테마/인기 종목 (단타 묘미 - 테마별로 빵 뜨는 종목 보너스) + # 상승률·거래증가율 상위 = 테마성 수요 대리 지표 (추후 테마 API 연동 시 교체 가능) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + theme_hot = self.client.get_price_change_rank(market="J", sort_type="1", limit=20) + if theme_hot: + for idx, item in enumerate(theme_hot): + code = item.get("stk_cd", "").strip() or item.get("code", "").strip() + if not code or len(code) != 6: + continue + bonus = (20 - idx) / 20.0 * 0.15 # 순위별 보너스 최대 +0.15 + if code in all_candidates: + all_candidates[code]["bonus_score"] += bonus + else: + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0) or 0)) + if current_price > 0 and (not slot_money or current_price <= slot_money): + all_candidates[code] = { + "code": code, + "name": item.get("stk_nm", code), + "price": current_price, + "base_score": 0.0, + "bonus_score": bonus, + "from_ant": False, + } + logger.info(f" ✅ [테마/인기] 상승률 상위 20개 보너스 반영 (테마성 수요 대리)") + except Exception as e: + logger.warning(f" ⚠️ [테마/인기] 수집 실패: {e}") + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 5. 외국인/기관 순매수 - 보너스 +0.3 (투자자 동향 기반) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -2491,6 +2475,13 @@ class ShortTradingBot: logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → 스캔 생략 (API에서 종목 리스트를 받지 못함)") return [] + # 후보 등록 방식: RELAXED면 낙폭+회복만 통과한 상위 N명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점에 적용) + # 기본 True → 후보 풀 확대(6개 수준), False면 기존처럼 전 필터 통과한 종목만 등록 + relaxed = get_env_bool("RELAXED_CANDIDATE_SCAN", False) + top_n = get_env_int("CANDIDATE_LIST_TOP_N", 6) + if relaxed: + logger.info(f" 📌 [RELAXED 모드] 낙폭+회복 통과만으로 후보 수집 → 상위 {top_n}명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점 적용)") + # 스캔 대상 리스트를 거래량/낙폭 체크 전에 한 번 출력 (종목명 · 코드) # 참고: 위 [스캔유니버스] 6소스(거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율) 합산 → 동일 개미털기 필터 적용 logger.info(f" 📋 [개미털기 스캔 대상] {len(scan_list)}개 (6소스 합산, 종목명 · 코드)") @@ -2503,7 +2494,7 @@ class ShortTradingBot: time.sleep(random.uniform(0.3, 0.6)) execution_strength_map = self.client.get_execution_strength_map(market="J", limit=200) if execution_strength_map: - logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +10점, 120+ → +20점)") + logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +1점, 120+ → +2점)") except Exception as e: logger.debug(f"체결강도 맵 로드 스킵: {e}") @@ -2598,52 +2589,54 @@ class ShortTradingBot: logger.warning(f" ⚠️ SK증권(001510) 회복률부족으로 탈락: 회복률={recovery_pos*100:.1f}%, 기준={self.min_recovery_ratio*100:.0f}%") continue - # [필터 3] 피뢰침 방지 - 고점 추격 매수 방지 - high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) - if current_price >= high_price * high_chase_threshold: - filter_counts["피뢰침(고점근접)"] += 1 - drop_from_high = (high_price - current_price) / high_price * 100 - logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}") - continue - - # [필터 4] 피뢰침 방지 - 급등주 제외 - if low_price > 0: - daily_change_pct = (high_price - low_price) / low_price * 100 - else: - daily_change_pct = 0 - max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) - if daily_change_pct > max_daily_change: - filter_counts["피뢰침(급등주)"] += 1 - logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}") - continue - - # [필터 5] RSI 과열 체크 (분봉 데이터 필요) - try: - df = self.client.get_minute_chart(code, period="3", limit=20) - if not df.empty and len(df) >= 14 and "RSI" in df.columns: - rsi = float(df["RSI"].iloc[-1]) - rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) - if rsi >= rsi_threshold: - filter_counts["RSI과열"] += 1 - logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}") - continue - - # [필터 6] MA20 체크 - if "MA20" in df.columns and len(df) >= 20: - ma20 = float(df["MA20"].iloc[-1]) - if current_price < ma20: - filter_counts["MA20"] += 1 - logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}") + # [필터 3~6] RELAXED 모드가 아닐 때만 적용 (후보 풀 확대 시 등록은 넓게, 매수 시점에 엄격 적용) + if not relaxed: + # [필터 3] 피뢰침 방지 - 고점 추격 매수 방지 + high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + if current_price >= high_price * high_chase_threshold: + filter_counts["피뢰침(고점근접)"] += 1 + drop_from_high = (high_price - current_price) / high_price * 100 + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}") + continue + + # [필터 4] 피뢰침 방지 - 급등주 제외 + if low_price > 0: + daily_change_pct = (high_price - low_price) / low_price * 100 + else: + daily_change_pct = 0 + max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) + if daily_change_pct > max_daily_change: + filter_counts["피뢰침(급등주)"] += 1 + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}") + continue + + # [필터 5] RSI 과열 체크 (분봉 데이터 필요) + try: + df = self.client.get_minute_chart(code, period="3", limit=20) + if not df.empty and len(df) >= 14 and "RSI" in df.columns: + rsi = float(df["RSI"].iloc[-1]) + rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) + if rsi >= rsi_threshold: + filter_counts["RSI과열"] += 1 + logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}") continue - ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0) - if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100): - filter_counts["MA20"] += 1 - gap_pct = (current_price - ma20) / ma20 * 100 - logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}") - continue - except Exception as e: - logger.debug(f"RSI/MA20 체크 실패({code}): {e}") + # [필터 6] MA20 체크 + if "MA20" in df.columns and len(df) >= 20: + ma20 = float(df["MA20"].iloc[-1]) + if current_price < ma20: + filter_counts["MA20"] += 1 + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}") + continue + + ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0) + if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100): + filter_counts["MA20"] += 1 + gap_pct = (current_price - ma20) / ma20 * 100 + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}") + continue + except Exception as e: + logger.debug(f"RSI/MA20 체크 실패({code}): {e}") # 외국인/기관 동향 확인 investor_trend = self.client.get_investor_trend(code, days=3) @@ -2684,18 +2677,21 @@ class ShortTradingBot: except Exception as e: logger.debug(f"ML 예측 실패({code}): {e}") - # 점수 계산: 낙폭 + 회복률 + 거래량 + 수급 + ML 승률 - score = (drop_rate * 100) + (recovery_pos * 50) + investor_score - if volume > 1000000: # 거래량 100만주 이상 가산점 - score += 10 + # 강도(점수) 계산: 스케일 0~15 전후 (10=높은 편, 5 전후=평범, 한투 체결강도/수급은 소폭 가산) + # 기존 (drop*100 + rec*50)은 30~50대라 평범한 구간이 없었음 → 10 단위로 조정 + score = (drop_rate * 10) + (recovery_pos * 10) # 낙폭·회복 기여 (각 0~10 수준) + if investor_score >= 10: # 수급 보너스 (0 / 1 / 2점) + score += 2 if investor_score >= 20 else 1 + if volume > 1000000: # 거래량 100만주 이상 +1점 + score += 1 if ml_prob is not None: - score += (ml_prob - 0.5) * 100 # ML 승률 가산점 - # 체결강도 상위 API(FHPST01710000) 보너스: 100 이상 +10점, 120 이상 +20점 + score += (ml_prob - 0.5) * 10 # ML 승률 -5~+5점 + # 체결강도 보너스: 100 이상 +1점, 120 이상 +2점 (과도한 가산 방지) execution_strength = execution_strength_map.get(code, 0) if execution_strength >= 120: - score += 20 + score += 2 elif execution_strength >= 100: - score += 10 + score += 1 candidate = { "code": code, "name": name, @@ -2713,20 +2709,21 @@ class ShortTradingBot: candidates.append(candidate) passed_filters += 1 - # 통과 즉시 DB 저장 (매매 루프가 스캔 완료를 기다리지 않고 실시간으로 후보 읽기 위함) + # 통과 즉시 DB 저장 (RELAXED가 아닐 때만; RELAXED면 루프 끝나고 상위 N명만 일괄 등록) # 같은 종목 중복 시 ON CONFLICT(code) DO UPDATE 로 최신 점수/가격으로 갱신됨 - try: - self.db.add_target_candidate({ - "code": code, - "name": name, - "score": score, - "price": current_price, - }) - except Exception as e: - logger.debug(f"후보 즉시 저장 실패({code}): {e}") + if not relaxed: + try: + self.db.add_target_candidate({ + "code": code, + "name": name, + "score": score, + "price": current_price, + }) + except Exception as e: + logger.debug(f"후보 즉시 저장 실패({code}): {e}") ml_info = f" | ML {ml_prob:.1%}" if ml_prob is not None else "" - strength_info = f" | 체결강도 {execution_strength:.0f}(+{'20' if execution_strength >= 120 else '10'}점)" if execution_strength >= 100 else "" + strength_info = f" | 체결강도 {execution_strength:.0f}(+{'2' if execution_strength >= 120 else '1'}점)" if execution_strength >= 100 else "" logger.info( f"{LOG_GREEN}✅ [통과] {name} {code}: 낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 강도 {score:.1f}{strength_info}{ml_info}{LOG_RESET}" ) @@ -2741,6 +2738,21 @@ class ShortTradingBot: candidates.sort(key=lambda x: x["score"], reverse=True) + # RELAXED 모드: 낙폭+회복만 통과한 풀에서 상위 N명만 DB 등록 (후보 풀 확대) + if relaxed and candidates: + n_register = min(top_n, len(candidates)) + for c in candidates[:n_register]: + try: + self.db.add_target_candidate({ + "code": c["code"], + "name": c.get("name", c["code"]), + "score": c["score"], + "price": c.get("price", 0), + }) + except Exception as e: + logger.debug(f"후보 등록 실패({c.get('code')}): {e}") + logger.info(f" 📌 [RELAXED] 상위 {n_register}명 DB 등록 (후보 풀 {len(candidates)}개 중 점수순)") + # 필터별 탈락/통과 요약 (색상: 탈락=노랑, 통과=초록) summary = ", ".join(f"{k}={v}" for k, v in filter_counts.items() if v > 0) logger.info(f" 📊 [필터 요약] 스캔 {total_scanned}개 중 {LOG_YELLOW}탈락: {summary or '없음'}{LOG_RESET} | {LOG_GREEN}통과: {len(candidates)}개{LOG_RESET}") diff --git a/kiwoom_trader_ver2.py b/kiwoom_trader_ver2.py new file mode 100644 index 0000000..f07ee0c --- /dev/null +++ b/kiwoom_trader_ver2.py @@ -0,0 +1,1045 @@ +""" +Kiwoom Trading Bot Ver2 - DB 기반 고급 트레이딩 시스템 +- SQLite DB 기반 안전한 데이터 관리 +- 변동성 기반 자금 관리 (Risk Manager) +- TWAP 스마트 분할 매수 (Smart Executor) +- 하프 켈리 공식 적용 +- 기존 TAIL_CATCH_3M 전략 유지 및 강화 +""" + +import time +import json +import datetime +import pandas as pd +import numpy as np +import os +import logging +import requests +from dotenv import load_dotenv + +# 새로운 모듈 임포트 +from database import TradeDB +from risk_manager import RiskManager +from smart_executor import SmartOrderExecutor + +# ========================================================== +# [Step 0] 환경 변수 및 기본 설정 +# ========================================================== +current_dir = os.path.dirname(os.path.abspath(__file__)) +env_path = os.path.join(current_dir, ".env") +if not os.path.exists(env_path): + env_path = os.path.join(os.path.dirname(current_dir), ".env") + +load_dotenv(env_path) + +# Mattermost 설정 +MM_SERVER_URL = "https://mattermost.hoonfam.org" +MM_BOT_TOKEN = os.environ.get("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = os.path.join(current_dir, "mm_config.json") + +# [Logger 설정] +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO +) +logger = logging.getLogger("TradingBotV2") + +# 외부 라이브러리 로그 억제 +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("requests").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) + +# 키움 API 모듈 임포트 +try: + from kiwoom_rest_api.auth.token import TokenManager + from kiwoom_rest_api.koreanstock.stockinfo import StockInfo + from kiwoom_rest_api.koreanstock.chart import Chart + from kiwoom_rest_api.koreanstock.order import Order + from kiwoom_rest_api.koreanstock.rank_info import RankInfo + from kiwoom_rest_api.koreanstock.account import Account + from kiwoom_rest_api.koreanstock.market_condition import MarketCondition +except ImportError as e: + logger.critical(f"❌ 키움 REST API 모듈 임포트 실패: {e}") + raise e + + +# ========================================================== +# [Part 0] Mattermost 봇 클래스 +# ========================================================== +class MattermostBot: + def __init__(self): + self.api_url = f"{MM_SERVER_URL.rstrip('/')}/api/v4/posts" + self.headers = { + "Authorization": f"Bearer {MM_BOT_TOKEN}", + "Content-Type": "application/json" + } + self.channels = self._load_channels() + + def _load_channels(self): + try: + if os.path.exists(MM_CONFIG_FILE): + with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f).get("channels", {}) + return {} + except Exception as e: + logger.error(f"⚠️ MM 설정 로드 실패: {e}") + return {} + + def send(self, channel_alias, message): + channel_id = self.channels.get(channel_alias) + if not channel_id: + logger.warning(f"❌ '{channel_alias}' 채널 ID 없음") + return False + + payload = {"channel_id": channel_id, "message": message} + try: + res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3) + res.raise_for_status() + return True + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + return False + + +# ========================================================== +# [Part 1] 브로커 API (키움증권 REST API 연동) +# ========================================================== +class BrokerAPI: + def __init__(self): + logger.info("🔵 키움(REST) 브로커 연결 시도...") + try: + self.token_manager = TokenManager() + self.stock_info = StockInfo(token_manager=self.token_manager) + self.chart = Chart(token_manager=self.token_manager) + self.order = Order(token_manager=self.token_manager) + self.rank = RankInfo(token_manager=self.token_manager) + self.account = Account(token_manager=self.token_manager) + self.market = MarketCondition(token_manager=self.token_manager) + self.acc_no = os.environ.get("KIWOOM_ACCOUNT_NO", "") + logger.info(f"✅ 브로커 연결 완료 (계좌: {self.acc_no})") + except Exception as e: + logger.critical(f"❌ 브로커 초기화 실패: {e}") + raise e + + def _safe_request(self, func, *args, **kwargs): + """API 호출 안전장치 (429 에러 핸들링)""" + full_name = func.__name__ + api_id = full_name.split('_')[-1] + max_retries = 3 + + for i in range(max_retries): + try: + time.sleep(1) # 기본 안전 대기 + result = func(*args, **kwargs) + + if isinstance(result, dict) and result.get('return_code') != '0' and '초과' in result.get('msg1', ''): + raise Exception("429 Rate Limit Detected") + + return result + + except Exception as e: + if "429" in str(e) or "과부하" in str(e): + logger.warning(f"⚠️ [{api_id}] API 과부하 -> 5초 대기 ({i + 1}/{max_retries})") + logger.info("5초 대기 시작...") + time.sleep(5) + logger.info("5초 대기 완료, 재시도합니다.") + else: + logger.error(f"❌ [{api_id}] 호출 에러: {e}") + time.sleep(1) + + logger.error(f"💀 [{api_id}] 3회 재시도 실패") + return {} + + def get_deposit_only(self): + """예수금 조회""" + try: + res = self._safe_request(self.account.deposit_detail_status_request_kt00001, qry_tp="2") + d2_deposit = float(res.get('d2_entra', 0)) if res else 0 + current_deposit = float(res.get('ord_alow_amt', 0)) if res else 0 + return 0 if d2_deposit < 0 else current_deposit + except Exception as e: + logger.error(f"예수금 조회 실패: {e}") + return 0 + + def get_account_info(self): + """계좌 평가 정보 조회 (전체 자산, 주식 평가액 등)""" + try: + res = self._safe_request(self.account.account_evaluation_balance_detail_request_kt00018, + qry_tp="2", prd_tp="1") + if not res: + return {'total_asset': 0, 'deposit': 0, 'stock_value': 0, 'profit_rate': 0} + + total_asset = float(res.get('estm_ast_amt', 0)) + deposit = float(res.get('ord_alow_amt', 0)) + stock_value = float(res.get('stck_pbls_amt', 0)) + + # 평가손익률 계산 + deposit_clean = float(res.get('d2_entra', deposit)) + profit_rate = 0.0 + if (deposit_clean + stock_value) > 0: + profit_rate = ((total_asset - (deposit_clean + stock_value)) / (deposit_clean + stock_value)) * 100 + + return { + 'total_asset': total_asset, + 'deposit': deposit, + 'stock_value': stock_value, + 'profit_rate': profit_rate + } + except Exception as e: + logger.error(f"계좌 정보 조회 실패: {e}") + return {'total_asset': 0, 'deposit': 0, 'stock_value': 0, 'profit_rate': 0} + + def check_market_status(self): + """장 운영 시간 체크 (08:30 ~ 16:00)""" + now = datetime.datetime.now() + if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)): + return False + if now.weekday() >= 5: # 주말 + return False + return True + + def get_ohlcv(self, code, timeframe='3m', limit=100): + """분봉 차트 데이터 조회""" + tic_scope = {"1m": "1", "3m": "3", "5m": "5", "10m": "10"}.get(timeframe, "3") + + try: + res = self._safe_request( + self.chart.stock_minute_chart_request_ka10080, + stk_cd=code, tic_scope=tic_scope, upd_stkpc_tp="1" + ) + data = res.get('stk_min_pole_chart_qry', []) if res else [] + if not data: + return pd.DataFrame() + + df = pd.DataFrame(data) + df = df.rename(columns={ + 'cur_prc': 'close', 'open_pric': 'open', + 'high_pric': 'high', 'low_pric': 'low', + 'trde_qty': 'volume' + }) + + # 시간순 정렬 (과거->현재) + df = df[['open', 'high', 'low', 'close', 'volume']].astype(float).abs() + return df.iloc[::-1].reset_index(drop=True).tail(limit) + + except Exception as e: + logger.error(f"차트 조회 실패({code}): {e}") + return pd.DataFrame() + + def get_current_price(self, code): + """현재가 조회""" + try: + res = self._safe_request( + self.stock_info.watchlist_stock_information_request_ka10095, + stock_code=code + ) + if res and 'atn_stk_infr' in res and len(res['atn_stk_infr']) > 0: + item = res['atn_stk_infr'][0] + return abs(float(item.get('cur_prc', 0))) + return None + except Exception as e: + logger.error(f"현재가 조회 에러({code}): {e}") + return None + + def buy_market_order(self, code, qty): + """시장가 매수 주문""" + try: + res = self._safe_request( + self.order.stock_buy_order_request_kt10000, + dmst_stex_tp="KRX", stk_cd=code, + ord_qty=str(qty), trde_tp="3", ord_uv="0" + ) + if str(res.get('return_code')) == '0': + return True + logger.error(f"매수 주문 실패({code}): {res}") + return False + except Exception as e: + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문""" + try: + res = self._safe_request( + self.order.stock_sell_order_request_kt10001, + dmst_stex_tp="KRX", stk_cd=code, + ord_qty=str(qty), trde_tp="3", ord_uv="0" + ) + if str(res.get('return_code')) == '0': + return True + logger.error(f"매도 주문 실패({code}): {res}") + return False + except Exception as e: + logger.error(f"매도 주문 예외({code}): {e}") + return False + + +# ========================================================== +# [Part 2] 메인 트레이딩 봇 Ver2 +# ========================================================== +class TradingBotV2: + def __init__(self, broker_api): + self.api = broker_api + + # Mattermost 초기화 + self.mm = MattermostBot() + self.mm_channel = "stock" + + # 파일 경로 + self.bot_state_file = os.path.join(current_dir, 'bot_state.json') + + # DB 초기화 + self.db = TradeDB(db_path="quant_bot.db") + + # Risk Manager 초기화 + kelly_enabled = os.environ.get("USE_KELLY", "false").lower() == "true" + self.risk_mgr = RiskManager( + risk_pct_per_trade=float(os.environ.get("RISK_PCT_PER_TRADE", "0.02")), + max_position_pct=float(os.environ.get("MAX_POSITION_PCT", "0.20")), + min_position_amount=int(os.environ.get("MIN_POSITION_AMOUNT", "50000")), + use_kelly=kelly_enabled + ) + + # Smart Executor 초기화 (TWAP 분할 매수) + use_twap = os.environ.get("USE_TWAP", "false").lower() == "true" + self.use_twap = use_twap + if use_twap: + self.executor = SmartOrderExecutor( + min_split_amount=int(os.environ.get("TWAP_MIN_SPLIT", "500000")), + max_split_amount=int(os.environ.get("TWAP_MAX_SPLIT", "2000000")), + min_delay_seconds=int(os.environ.get("TWAP_MIN_DELAY", "30")), + max_delay_seconds=int(os.environ.get("TWAP_MAX_DELAY", "180")) + ) + else: + self.executor = None + + # 거래 설정 + self.max_stocks = int(os.environ.get("MAX_STOCKS", "5")) + self.stop_loss_pct = float(os.environ.get("STOP_LOSS_PCT", "-0.035")) + self.daily_stop_loss_pct = float(os.environ.get("DAILY_STOP_LOSS_PCT", "-0.05")) + + # 전략 파라미터 + self.rsi_threshold = float(os.environ.get("RSI_OVERHEAT_THRESHOLD", "73")) + self.shoulder_cut_pct = float(os.environ.get("SHOULDER_CUT_PCT", "0.03")) + self.min_recovery_ratio = float(os.environ.get("MIN_RECOVERY_RATIO", "0.5")) + self.max_recovery_ratio = float(os.environ.get("MAX_RECOVERY_RATIO", "0.8")) + + # 상태 변수 + self.current_cash = 0 + self.current_total_asset = 0 + self.start_asset = 0 + self.prev_session_asset = 0 # 이전 실행 시 자산 + self.start_day_asset = 0 # 오늘 장 시작 시 자산 + self.today_date = datetime.datetime.now().strftime("%Y%m%d") + self.trading_halted = False + + # 리포트 플래그 + self.morning_report_sent = False # 오전 장 뜸할 때 (13:00) + self.closing_report_sent = False # 장마감 전 (15:15) + self.final_report_sent = False # 장마감 후 (15:35) + + # 봇 상태 로드 (이전 실행 정보) + self._load_bot_state() + + # 초기 계좌 정보 로드 + self.refresh_account() + + # JSON 마이그레이션 (최초 1회) + self._migrate_from_json_if_needed() + + # 시작 메시지 + self._send_startup_message(kelly_enabled, use_twap) + + # 봇 상태 저장 + self._save_bot_state() + + def _load_bot_state(self): + """이전 실행 시 봇 상태 로드""" + try: + bot_state_file = getattr(self, 'bot_state_file', None) or os.path.join(current_dir, 'bot_state.json') + if os.path.exists(bot_state_file): + with open(self.bot_state_file, 'r', encoding='utf-8') as f: + state = json.load(f) + + self.prev_session_asset = float(state.get('start_equity', 0)) + prev_day = state.get('start_day', '') + + # 날짜가 바뀌었으면 오늘 시작 자산 갱신 필요 + if prev_day != self.today_date: + self.start_day_asset = 0 # 계좌 조회 후 설정 + logger.info(f"📅 날짜 변경 감지: {prev_day} → {self.today_date}") + else: + self.start_day_asset = self.prev_session_asset + + logger.info(f"📂 봇 상태 로드: 이전 자산 {self.prev_session_asset:,.0f}원") + else: + logger.info("📂 봇 상태 파일 없음 (최초 실행)") + self.prev_session_asset = 0 + self.start_day_asset = 0 + except Exception as e: + logger.error(f"❌ 봇 상태 로드 실패: {e}") + self.prev_session_asset = 0 + self.start_day_asset = 0 + + def _save_bot_state(self): + """현재 봇 상태 저장""" + try: + state = { + 'start_equity': self.current_total_asset, + 'start_day': self.today_date, + 'last_update': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + + with open(self.bot_state_file, 'w', encoding='utf-8') as f: + json.dump(state, f, indent=4, ensure_ascii=False) + + except Exception as e: + logger.error(f"❌ 봇 상태 저장 실패: {e}") + + def _send_startup_message(self, kelly_enabled, use_twap): + """시작 메시지 전송 (이전 실행 대비 손익률 포함)""" + + # 이전 실행 대비 손익률 계산 + if self.prev_session_asset > 0: + session_pnl = self.current_total_asset - self.prev_session_asset + session_pnl_pct = (session_pnl / self.prev_session_asset) * 100 + session_info = f"\n- 이전 실행 대비: {session_pnl:+,.0f}원 ({session_pnl_pct:+.2f}%)" + else: + session_info = "\n- 이전 실행 대비: 데이터 없음 (최초 실행)" + + # 오늘 장 시작 대비 손익률 + if self.start_day_asset > 0 and self.start_day_asset != self.current_total_asset: + day_pnl = self.current_total_asset - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset) * 100 + day_info = f"\n- 오늘 장 시작 대비: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)" + else: + day_info = "" + + msg = ( + f"🤖 **[트레이딩봇 Ver2 가동]**\n" + f"- 현재 자산: {self.current_total_asset:,.0f}원" + f"{session_info}" + f"{day_info}\n" + f"- DB 기반 안전 관리\n" + f"- 변동성 기반 자금 관리\n" + f"- TWAP: {'ON' if use_twap else 'OFF'}\n" + f"- 켈리 공식: {'ON' if kelly_enabled else 'OFF'}" + ) + + logger.info(msg) + self.send_mm(msg) + + def _migrate_from_json_if_needed(self): + """기존 JSON 파일에서 DB로 마이그레이션 (1회성)""" + portfolio_file = os.path.join(current_dir, 'portfolio.json') + if os.path.exists(portfolio_file): + try: + with open(portfolio_file, 'r', encoding='utf-8') as f: + data = json.load(f) + if data: + count = self.db.migrate_from_json(data) + logger.info(f"📦 JSON 마이그레이션 완료: {count}개 종목") + # 백업 후 삭제 + backup_path = portfolio_file + ".backup" + os.rename(portfolio_file, backup_path) + logger.info(f"💾 기존 JSON 백업: {backup_path}") + except Exception as e: + logger.error(f"❌ JSON 마이그레이션 실패: {e}") + + def send_mm(self, msg): + """Mattermost 알림 전송""" + try: + self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + + def refresh_account(self): + """계좌 정보 갱신""" + try: + info = self.api.get_account_info() + self.current_cash = info['deposit'] + self.current_total_asset = info['total_asset'] + + # 첫 실행 시 시작 자산 설정 + if self.start_asset == 0: + self.start_asset = info['total_asset'] + + # 오늘 장 시작 자산 설정 (날짜 바뀜 or 최초 실행) + if self.start_day_asset == 0: + self.start_day_asset = info['total_asset'] + logger.info(f"📅 오늘 장 시작 자산 설정: {self.start_day_asset:,.0f}원") + + # 손익률 계산 + 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 + + logger.info( + f"💰 예수금: {self.current_cash:,.0f}원 | " + f"총자산: {self.current_total_asset:,.0f}원 " + f"(예수금 {self.current_cash:,.0f} + 주식 {info['stock_value']:,.0f}) | " + f"오늘: {day_pnl:+,.0f}원({day_pnl_pct:+.2f}%)" + ) + + except Exception as e: + logger.error(f"❌ 계좌 정보 갱신 실패: {e}") + + def calculate_rsi(self, df, period=14): + """RSI 계산""" + try: + if df is None or len(df) < period + 1: + return 50 + + delta = df['close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + return rsi.iloc[-1] if not np.isnan(rsi.iloc[-1]) else 50 + except: + return 50 + + def calculate_atr(self, df, period=14): + """ATR 계산""" + try: + if df is None or len(df) < period: + return 0 + + df = df.copy() + df['tr'] = np.maximum( + df['high'] - df['low'], + np.maximum( + np.abs(df['high'] - df['close'].shift()), + np.abs(df['low'] - df['close'].shift()) + ) + ) + atr = df['tr'].rolling(window=period).mean().iloc[-1] + return atr if not np.isnan(atr) else 0 + except: + return 0 + + def check_buy_signal_tail_catch(self, code, name): + """ + TAIL_CATCH_3M 전략: 3분봉 꼬리 잡기 + - 기존 로직 유지 + 변동성 기반 비중 계산 추가 + """ + try: + df = self.api.get_ohlcv(code, timeframe='3m', limit=50) + if df is None or len(df) < 20: + return None + + current_price = df['close'].iloc[-1] + candle = df.iloc[-1] + + # 1. RSI 과열 필터 + rsi = self.calculate_rsi(df) + if rsi > self.rsi_threshold: + return None + + # 2. 일일 고점 추격 방지 + daily_high = df['high'].max() + if current_price > daily_high * 0.96: + return None + + # 3. 아래꼬리 확인 + candle_low = candle['low'] + candle_high = candle['high'] + candle_open = candle['open'] + candle_close = candle['close'] + + body_top = max(candle_open, candle_close) + body_bottom = min(candle_open, candle_close) + + tail_length = body_bottom - candle_low + body_length = body_top - body_bottom + + if tail_length <= 0 or body_length <= 0: + return None + + # 꼬리 길이 비율 확인 + tail_ratio = tail_length / body_length + tail_pct = tail_length / candle_low + + if tail_ratio < 1.5 or tail_pct < 0.003: + return None + + # 4. 회복 속도 확인 + total_range = candle_high - candle_low + if total_range <= 0: + return None + + recovery_ratio = (current_price - candle_low) / total_range + + if not (self.min_recovery_ratio <= recovery_ratio <= self.max_recovery_ratio): + return None + + # 5. 변동성 계산 및 매수 금액 산출 + atr = self.calculate_atr(df) + + # 켈리 비율 가져오기 + kelly_fraction = None + if self.risk_mgr.use_kelly: + kelly_fraction = self.db.calculate_half_kelly() + + # Risk Manager를 통한 안전 매수 금액 계산 + safe_amount = self.risk_mgr.get_position_size( + stock_name=name, + current_balance=self.current_cash, + df=df, + kelly_fraction=kelly_fraction + ) + + if safe_amount < self.risk_mgr.min_amount: + logger.info(f"🚫 [{name}] 계산된 금액이 최소 매수액 미달") + return None + + # 6. 수량 계산 + qty = self.risk_mgr.calculate_quantity(current_price, safe_amount) + + if qty < 1: + return None + + # 손절가/목표가 설정 (ATR 기반) + atr_multiplier_stop = 3.5 + atr_multiplier_target = 8.0 + + stop_price = current_price - (atr * atr_multiplier_stop) + target_price = current_price + (atr * atr_multiplier_target) + + logger.info( + f"✅ [{name}] TAIL_CATCH 시그널 발생 | " + f"가격:{current_price:,.0f} | RSI:{rsi:.1f} | " + f"꼬리비율:{tail_ratio:.2f} | 회복:{recovery_ratio:.2%} | " + f"매수:{safe_amount:,.0f}원({qty}주)" + ) + + return { + 'code': code, + 'name': name, + 'price': current_price, + 'qty': qty, + 'amount': safe_amount, + 'strategy': 'TAIL_CATCH_3M', + 'stop_price': stop_price, + 'target_price': target_price, + 'atr': atr + } + + except Exception as e: + logger.error(f"❌ [{name}] 매수 시그널 체크 실패: {e}") + return None + + def execute_buy(self, signal): + """매수 실행 (TWAP 분할 매수 또는 즉시 매수)""" + try: + code = signal['code'] + name = signal['name'] + qty = signal['qty'] + amount = signal['amount'] + + # DB에 이미 있는지 확인 + active_trades = self.db.get_active_trades() + if code in active_trades: + logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵") + return False + + # TWAP 활성화 시 스마트 주문 등록 + if self.use_twap and self.executor and amount >= 1000000: # 100만원 이상만 분할 + self.executor.add_order(code, name, amount, duration_minutes=30) + logger.info(f"📝 [{name}] TWAP 분할 매수 등록: {amount:,.0f}원") + return True + + # 일반 매수 (즉시 실행) + success = self.api.buy_market_order(code, qty) + + if success: + # DB에 저장 + trade_data = { + 'code': code, + 'name': name, + 'avg_buy_price': signal['price'], + 'stop_price': signal['stop_price'], + 'target_price': signal['target_price'], + 'atr_entry': signal['atr'], + 'target_qty': qty, + 'current_qty': qty, + 'total_invested': signal['price'] * qty, + 'status': 'HOLDING', + 'strategy': signal['strategy'] + } + + self.db.upsert_trade(trade_data) + + msg = f"💰 **[매수 체결]** {name}\n가격: {signal['price']:,.0f}원 × {qty}주" + logger.info(msg) + self.send_mm(msg) + + return True + + return False + + except Exception as e: + logger.error(f"❌ 매수 실행 실패: {e}") + return False + + def check_sell_signals(self): + """보유 종목 매도 시그널 체크""" + active_trades = self.db.get_active_trades() + + if not active_trades: + return + + for code, trade in list(active_trades.items()): + try: + name = trade['name'] + buy_price = trade['avg_buy_price'] + stop_price = trade['stop_price'] + target_price = trade['target_price'] + qty = trade['current_qty'] + + # 현재가 조회 + current_price = self.api.get_current_price(code) + if not current_price: + continue + + # DB 현재가 업데이트 + self.db.update_current_price(code, current_price) + + # 손익률 계산 + profit_rate = ((current_price - buy_price) / buy_price) * 100 + + # 매도 사유 판단 + sell_reason = None + + # 1. 손절 + if current_price <= stop_price: + sell_reason = f"손절({profit_rate:.1f}%)" + + # 2. 목표가 달성 + elif current_price >= target_price: + sell_reason = f"목표달성({profit_rate:.1f}%)" + + # 3. 어깨 매도 (고점 대비 하락) + max_price = trade.get('max_price', buy_price) + if current_price > max_price: + self.db.update_max_price(code, current_price) + else: + drop_from_high = (max_price - current_price) / max_price + if drop_from_high > self.shoulder_cut_pct: + sell_reason = f"어깨매도({profit_rate:.1f}%)" + + # 매도 실행 + if sell_reason: + if self.api.sell_market_order(code, qty): + self.db.close_trade(code, current_price, sell_reason) + + msg = f"📤 **[매도 체결]** {name}\n사유: {sell_reason}\n수익: {profit_rate:+.2f}%" + logger.info(msg) + self.send_mm(msg) + + except Exception as e: + logger.error(f"❌ [{code}] 매도 체크 실패: {e}") + + def send_morning_report(self): + """오전 장 뜸할 때 리포트 (13:00)""" + try: + # 계좌 정보 갱신 + info = self.api.get_account_info() + + # 손익 계산 + day_pnl = info['total_asset'] - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + # 보유 종목 정보 + active_trades = self.db.get_active_trades() + holdings_info = "" + + if active_trades: + holdings_info = "\n\n**보유 종목:**" + for code, trade in active_trades.items(): + current_price = self.api.get_current_price(code) + if current_price: + profit = ((current_price - trade['avg_buy_price']) / trade['avg_buy_price']) * 100 + holdings_info += f"\n- {trade['name']}: {profit:+.2f}%" + + # 오늘 거래 통계 + today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') + cursor = self.db.conn.execute( + "SELECT COUNT(*) as cnt, SUM(realized_pnl) as pnl FROM trade_history WHERE sell_date >= ?", + (today_start,) + ) + row = cursor.fetchone() + today_trades = row['cnt'] if row else 0 + today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 + + msg = ( + f"🌞 **[오전 장 리포트 13:00]**\n" + f"- 시작 자산: {self.start_day_asset:,.0f}원\n" + f"- 현재 자산: {info['total_asset']:,.0f}원\n" + f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" + f"- 오늘 거래: {today_trades}건 ({today_trade_pnl:+,.0f}원)" + f"{holdings_info}" + ) + + logger.info(msg) + self.send_mm(msg) + self.morning_report_sent = True + + except Exception as e: + logger.error(f"❌ 오전 리포트 전송 실패: {e}") + + def send_closing_report(self): + """장마감 전 리포트 (15:15)""" + try: + info = self.api.get_account_info() + + day_pnl = info['total_asset'] - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + # 오늘 거래 통계 + today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') + cursor = self.db.conn.execute( + "SELECT COUNT(*) as cnt, SUM(realized_pnl) as pnl FROM trade_history WHERE sell_date >= ?", + (today_start,) + ) + row = cursor.fetchone() + today_trades = row['cnt'] if row else 0 + today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 + + msg = ( + f"🔔 **[장마감 전 리포트 15:15]**\n" + f"- 현재 자산: {info['total_asset']:,.0f}원\n" + f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" + f"- 오늘 거래: {today_trades}건 ({today_trade_pnl:+,.0f}원)\n" + f"- 보유 종목: {len(self.db.get_active_trades())}개" + ) + + logger.info(msg) + self.send_mm(msg) + self.closing_report_sent = True + + except Exception as e: + logger.error(f"❌ 장마감 전 리포트 전송 실패: {e}") + + def send_final_report(self): + """장마감 후 최종 리포트 (15:35)""" + try: + info = self.api.get_account_info() + + # 오늘 손익 + day_pnl = info['total_asset'] - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + # 누적 손익 (총 입금액 대비) + # 총 입금액은 환경 변수나 별도 파일로 관리 필요 + total_deposit = float(os.environ.get("TOTAL_DEPOSIT", str(self.start_day_asset))) + total_pnl = info['total_asset'] - total_deposit + total_pnl_pct = (total_pnl / total_deposit * 100) if total_deposit > 0 else 0 + + # 오늘 거래 통계 + today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') + cursor = self.db.conn.execute( + """SELECT + COUNT(*) as cnt, + SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins, + SUM(realized_pnl) as pnl + FROM trade_history + WHERE sell_date >= ?""", + (today_start,) + ) + row = cursor.fetchone() + today_trades = row['cnt'] if row else 0 + today_wins = row['wins'] if row else 0 + today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 + today_win_rate = (today_wins / today_trades * 100) if today_trades > 0 else 0 + + # 전체 통계 + stats = self.db.get_trade_stats() + + msg = ( + f"🌙 **[장마감 최종 리포트 15:35]**\n\n" + f"**📊 오늘 실적**\n" + f"- 시작: {self.start_day_asset:,.0f}원 → 종료: {info['total_asset']:,.0f}원\n" + f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" + f"- 오늘 거래: {today_trades}건 (익절 {today_wins}건, 승률 {today_win_rate:.1f}%)\n" + f"- 거래 손익: {today_trade_pnl:+,.0f}원\n\n" + f"**💰 누적 실적**\n" + f"- 총 입금액: {total_deposit:,.0f}원\n" + f"- 현재 자산: {info['total_asset']:,.0f}원\n" + f"- 누적 손익: {total_pnl:+,.0f}원 ({total_pnl_pct:+.2f}%)\n" + f"- 전체 거래: {stats['total_trades']}건 (승률 {stats['win_rate']:.1f}%)\n" + f"- 전체 손익: {stats['total_pnl']:+,.0f}원" + ) + + logger.info(msg) + self.send_mm(msg) + self.final_report_sent = True + + # 다음날을 위해 상태 저장 + self._save_bot_state() + + except Exception as e: + logger.error(f"❌ 최종 리포트 전송 실패: {e}") + + def process_twap_orders(self): + """TWAP 분할 매수 처리""" + if not self.use_twap or not self.executor: + return + + # 현재가 정보 수집 + active_orders = self.executor.get_status() + if not active_orders: + return + + current_prices = {} + for code in active_orders.keys(): + price = self.api.get_current_price(code) + if price: + current_prices[code] = price + + # 매수 콜백 함수 + def buy_callback(code, name, amount, price): + qty = int(amount / price) + if qty < 1: + return False + + success = self.api.buy_market_order(code, qty) + + if success: + # DB 업데이트 (분할 매수 누적) + active_trades = self.db.get_active_trades() + + if code in active_trades: + # 기존 보유분 있음 -> 평단가 계산 + existing = active_trades[code] + old_qty = existing['current_qty'] + old_price = existing['avg_buy_price'] + old_invested = existing['total_invested'] + + new_qty = old_qty + qty + new_invested = old_invested + (price * qty) + new_avg_price = new_invested / new_qty + + self.db.upsert_trade({ + 'code': code, + 'name': name, + 'avg_buy_price': new_avg_price, + 'current_qty': new_qty, + 'total_invested': new_invested, + 'status': 'HOLDING', + **existing # 기존 정보 유지 + }) + else: + # 신규 매수 + self.db.upsert_trade({ + 'code': code, + 'name': name, + 'avg_buy_price': price, + 'target_qty': qty, # 임시 + 'current_qty': qty, + 'total_invested': price * qty, + 'status': 'HOLDING', + 'strategy': 'TAIL_CATCH_3M' + }) + + return True + + return False + + # TWAP 처리 + self.executor.process_orders(current_prices, buy_callback) + + def run(self): + """메인 루프""" + logger.info("🚀 트레이딩봇 Ver2 가동 시작") + + loop_count = 0 + + while True: + try: + loop_count += 1 + now = datetime.datetime.now() + current_time = now.time() + + # 1. 장 운영 시간 체크 + if not self.api.check_market_status(): + # 장 시간 외: 플래그 초기화 + if now.weekday() < 5: # 주중 + # 날짜가 바뀌었는지 확인 + today_str = now.strftime("%Y%m%d") + if today_str != self.today_date: + logger.info(f"📅 날짜 변경: {self.today_date} → {today_str}") + self.today_date = today_str + self.start_day_asset = 0 + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + + if loop_count % 60 == 0: # 1분마다 + logger.info("💤 장 운영 시간 외") + time.sleep(60) + continue + + # 2. 장 중 리포트 타이밍 체크 + # 오전 장 뜸할 때: 13:00 + if not self.morning_report_sent and datetime.time(13, 0) <= current_time < datetime.time(13, 5): + self.send_morning_report() + + # 장마감 전: 15:15 + if not self.closing_report_sent and datetime.time(15, 15) <= current_time < datetime.time(15, 20): + self.send_closing_report() + + # 장마감 후: 15:35 (모든 체결 완료 후) + if not self.final_report_sent and datetime.time(15, 35) <= current_time < datetime.time(15, 40): + self.send_final_report() + + # 3. 계좌 정보 갱신 (5분마다) + if loop_count % 30 == 0: + self.refresh_account() + + # 4. TWAP 분할 매수 처리 + if self.use_twap: + self.process_twap_orders() + + # 5. 보유 종목 매도 체크 + self.check_sell_signals() + + # 6. 새로운 매수 기회 탐색 (보유 종목이 max보다 적을 때) + active_count = len(self.db.get_active_trades()) + + if active_count < self.max_stocks and self.current_cash >= self.risk_mgr.min_amount: + # 실제로는 scan_ant_shaking_candidates 같은 로직 필요 + # 여기서는 예시로 생략 + pass + + # 7. 대기 + time.sleep(10) + + except KeyboardInterrupt: + logger.info("⏸️ 사용자 중단") + break + except Exception as e: + logger.error(f"❌ 메인 루프 에러: {e}") + logger.info("⏳ 예외 발생 -> 5초 대기 후 재시도") + time.sleep(5) + + # 종료 처리 + logger.info("🛑 봇 종료 중...") + self._save_bot_state() # 최종 상태 저장 + if self.db: + self.db.close() + logger.info("✅ 정상 종료 완료") + + +# ========================================================== +# [메인 실행] +# ========================================================== +if __name__ == "__main__": + try: + broker = BrokerAPI() + bot = TradingBotV2(broker) + bot.run() + except Exception as e: + logger.critical(f"💀 봇 실행 실패: {e}") + raise e diff --git a/quant_bot.db b/quant_bot.db index 8da240b29f8acf9bf5f74177c2e8011dc3e46aeb..d9239d1d981fe29f74c6f18bddd74f3cf790e803 100644 GIT binary patch delta 517 zcmZp8z|ydQb%M0uYz77fQ6Po^$%#6~jI%c;%=c$h+$6wgu$i%eSwM)Dp^$->nd>{( z7N(UzCIf@i#K!2L7E^9^aeaNp4#|?lq@2{G(#rVMiqz!NlFaYUg8Eefx8Yfj{yRtiVuV<9`GSM-l$t{NMOL^1tDK&i`n$pukyv zCSm!_&(BLH@bChqnK;uJcs6sUZETFc F1OP(VwD14` delta 306 zcmZozz}oPDWrDO|1p@OQ6dQT>HNmX_xV`BpYVXtXNwm7(647+%CdoKGVBLgEdT>~RsBO?Vv3o9dYE1&?TkSim*q%6cJLsLV8VDC4} z=Dyn2eEnEy9W@kjnQ{Lg_JuJCVubzUq10NpB3i2wiq diff --git a/update_env_simple.py b/update_env_simple.py index 0e8b69d..296d355 100644 --- a/update_env_simple.py +++ b/update_env_simple.py @@ -55,8 +55,8 @@ if __name__ == "__main__": my_updates = { # 한투 API 설정 "KIS_MOCK": "true", - "KIS_APP_KEY_REAL": "PSUT2l4CO94DjrwDa2EAnEl639YXnWHdbbkN", # 여기에 앱키 입력 - "KIS_APP_SECRET_REAL": "2SkPBKrztpBomcR+pYNEBuVa5/iSqYLQxsDn/YqJuQ0dULp/GqTAePhe4czJHuf/1XBUd18KDV6ZTrmxfI8eTiCfIEaO6jMKSq0u+CoUkHTrO9TfliYtxsNbl43jL+rokLB54V2VmHrlqM4WCF+54bMWhzzSE7z3OOl67V9yWKCWIoTrcYg=", + #"KIS_APP_KEY_REAL": "PSUT2l4CO94DjrwDa2EAnEl639YXnWHdbbkN", # 여기에 앱키 입력 + #"KIS_APP_SECRET_REAL": "2SkPBKrztpBomcR+pYNEBuVa5/iSqYLQxsDn/YqJuQ0dULp/GqTAePhe4czJHuf/1XBUd18KDV6ZTrmxfI8eTiCfIEaO6jMKSq0u+CoUkHTrO9TfliYtxsNbl43jL+rokLB54V2VmHrlqM4WCF+54bMWhzzSE7z3OOl67V9yWKCWIoTrcYg=", # "KIS_APP_KEY_MOCK": "PSdfKtsMihgC9tLiUr2XISscuR3fHxl6kvmV", # 여기에 앱키 입력` # "KIS_APP_SECRET_MOCK": "Ip+XZrZcoz11thgDD40XS8i6R1AalYkKFZwg2w8+ZMulVKN8rJVXiqGONxc4EYxw1S3TgOcx7fSldDc6EGq63bprfbgHwKWxstu29ZmLAtRNU0oFqV7e9vCOfgiWxrfnCqwcihoS7ovmza9+Ylqd8/EtjFGNmhQHWocyTAm8kdp5IG6tFtc=", # 여기에 앱시크릿 입력 @@ -66,9 +66,10 @@ if __name__ == "__main__": # "KIS_ACCOUNT_NO_MOCK": "50169256", # "KIS_ACCOUNT_CODE_REAL": "01", # "KIS_ACCOUNT_CODE_MOCK": "01", - - "MIN_RECOVERY_RATIO": 0.35, - "MIN_DROP_RATE": 0.02, + #"MM_BOT_TOKEN_": "5o4bfsqo97dedyq7599wz6joie", + "TAKE_PROFIT_PCT_LONG": "0.010", + #"MIN_RECOVERY_RATIO": 0.35, + #"MIN_DROP_RATE": 0.02, }