From b827f03d56489419a3c0cf518b6f5f8b6b7e9a00 Mon Sep 17 00:00:00 2001 From: Hwang Date: Sun, 22 Feb 2026 18:05:14 +0900 Subject: [PATCH] source push --- __pycache__/database.cpython-312.pyc | Bin 0 -> 42862 bytes __pycache__/kis_short_ver1.cpython-312.pyc | Bin 0 -> 120301 bytes __pycache__/ml_predictor.cpython-312.pyc | Bin 0 -> 12006 bytes __pycache__/risk_manager.cpython-312.pyc | Bin 0 -> 10502 bytes check_account_config.py | 103 + check_db.py | 28 + copy_env_row_to_latest.py | 40 + database.py | 872 ++++++ init_db.py | 78 + kis_long_ver1.py | 1668 ++++++++++ kis_short_ver1.py | 3296 ++++++++++++++++++++ ml_predictor.py | 229 ++ modify_db_schema.py | 37 + quant_bot.db | Bin 0 -> 77824 bytes risk_manager.py | 270 ++ scripts/copy_env_row_to_latest.py | 0 update_env.py | 145 + update_env_short.py | 137 + update_env_simple.py | 83 + 19 files changed, 6986 insertions(+) create mode 100644 __pycache__/database.cpython-312.pyc create mode 100644 __pycache__/kis_short_ver1.cpython-312.pyc create mode 100644 __pycache__/ml_predictor.cpython-312.pyc create mode 100644 __pycache__/risk_manager.cpython-312.pyc create mode 100644 check_account_config.py create mode 100644 check_db.py create mode 100644 copy_env_row_to_latest.py create mode 100644 database.py create mode 100644 init_db.py create mode 100644 kis_long_ver1.py create mode 100644 kis_short_ver1.py create mode 100644 ml_predictor.py create mode 100644 modify_db_schema.py create mode 100644 quant_bot.db create mode 100644 risk_manager.py create mode 100644 scripts/copy_env_row_to_latest.py create mode 100644 update_env.py create mode 100644 update_env_short.py create mode 100644 update_env_simple.py diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2bd586146453dffd12220809f9a2a104ec8d17bd GIT binary patch literal 42862 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 AQvd(} literal 0 HcmV?d00001 diff --git a/__pycache__/ml_predictor.cpython-312.pyc b/__pycache__/ml_predictor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd19741009c3375a53105394ba665ad76b498c26 GIT binary patch literal 12006 zcmb_idvqIBdLO;tmSxMf{D?DtB(Z{Dc|fr9!mmJrV<2I9c%iE787Fe|Ff$U4T^S5( zsGMYL2a>e{4pF+{#A$Mhfb7v!)z8w^?kg5f`3bG%0q#P9Jz4Dv+g)-w`?*klb|G& z)G6td@Ts&@%BQkU8K25K<&esp3YW4|DZ#dKr^==3RJqihYW`X3)O2e3wz^Kel%VCk zMdDzolu}%T@i25|X^@K2X>?_GX7dzOPLnIAGl$Qsow+V^r`eU)ndi#y%!e{fX8}-E z(nTVL-Yo*9^!Q@pq4)muQX${DN=iIPP=*nL%DN~`l$8!hD+t{?D59lxIpf0Cu2uGSbRG~ z#)5&^^jUK8Z>AQ1IIXQA4>?#{m&aT8oX_s|Vy>3zik%LUUdB$*wu27V>tP1S#UF-a zr!Oy!gcr|Fl1nE~#g6<0`YxV77YhW**wN5Za2h);AETZU^h|xDy3>c(yy8!qelseD}e$K6@kdAJbzb+wC~y@YXnKJL4u7 z-}IN=C~mIW6yzQ;HEqr-Cw3N?J*&XjN~vNFbyzP&t%_)

SlxU$uN9kxiJR9IyKO*nUqH?ks z+Y*E~!8!!6@1Tw##BJ{_nDAxFQ*2=b)1Z-7(VY!0`> zYqJfS)3$r9Sh^NSvj-s>CgOU+vij44=5rP2dc!-SYnx`un*;J=`uU>DPm3P9B)ha{ zx-?q-;7rv+P->jdD}+S*g;Z0j4ajHJMR7AxUWvKVe>D~_=yS%}N82NX?X&v5^ZJ}? zYW7au^@P)qy^`ZBUXnx3WZ&w1m>% zWo`!Sk7M>$q%}`u#g&4YW>H45CI7{~xpnqwl{h~M;aBV@DhO|-NFS`2@*!o~eAkIJ zsgyGLm2VQKlY2lyJWWvQA>|9o0kx3!OOm!rj&BRd{nqB##CU8(v^7wvE}gg%dk;*E zpPq>QbW#h<&4*-WDL4Yg#Nv??%PkkQO8OX25AZVxO`2EK;b3g?3K}wCuGB3Zn<5ua zUs-%_YVrIrGWLVhv5|L|P6ltjAF58#=7&bwOD9I4y9bMX4r1A=uE|swP2>I>jtZ%S#^^xP!v3eUI#2zRGtrdGevQeQhaX zZ&}vPN5U&5OfbP%Oqru#Y7NKEzS~Ip^w8+$ zyO+tT*iR;7uO1_PD4?)l>EuZ4jT6=mPM5Y17!-`05;Q=+!|g*pjGuY^NW!vBCx3Qw z@%2d{k3vl#fBGk}P#{%OO~zgw=Syy&z2ZaJ3JtA3bVX2eF59>8WzWHmYb3##QnAm0 zO-gU#RNWr8o9^}s`U?qgGPkFnK`Vq)gCgzW&0uLC#bq%xXg%m?d(KBQ1DvAU>0xP3 z21Wq08k#dGE;v;`Sf~#70ZwUWy#swv?{IrLxx?M#0mbZdahkTn-E<$Sex@8cSXB%% zG{1K^InIXDb^E-Yo*sTHa0+2CDb>SCXcqNDVly#oa5xKn7`95c*Ot)etgVOfxNKC{ zU}d@jy<4j$pux7o;(~0*YvM{`?S|0aI3e9=h^rKN*@3p(SwxO`&Qu;X zm51(~s+=*^1)Ase*<*V~_Z(}#Zp<0qc(gxgo-3@27S?@gtXs&=pU66%HJ^XqWp;Yw z`~6|ZwEvf_bM0Nx_O8go-H~p0#O%2(lbUj1EKA9?lG5N%w4^31iI!}bD{hDuH(aTh zDc%ui4jdkBM}7YA=)?0l^_O$Q``#}IYvP2Yv1A@P9X{1QS5h48(=(FWGMOPOt|iR*f!%++ZnS(sNMV^0 z21D_^VS9Lgq_AOD-?)%l@O4}Pm0z*A8(waawr(UoRy0*Z^2tU`+bY>7TPNtRqoYaHi8)H1Mn?g3lBR~(0>)BaN-CuxsbBh%#hWxcz-UO!1NtT{_EcY9{inZ+ zDOD%tD78of-zDw6Gqu&E+I~w*ZCcA5ajagmm`kO-1*yE4O9uqJsO9;P3G(oMGwlqf z2&JbCnO5*3?gR+`4~k%9e8&{CDbsgMG3Ps{m`jZ!J@zZbSq*8t_lPxqwO=Dr8`64f(#xsrOXcF18Pa*{?pmk+mi4F*M+R3ZCC4+Z zT%})2t$9oNDk55mXUhoU8CbiatRdsK^uNcS1&&s=-$>QKN~USFV-<4T zFAXau3Xh_L1{z0Ut>rCF0YXcdr9Rv*E3udq4w*t`6Zqq<4j;O>pH04kUQg`oBWHpSSv@2%stwY4`WXl9jjdbbTADu>Gs_%_RFT=v5@3XJuG0z{So#{@u|!S@5C+b~Eo z9vkI&o=38MsQ&S@C%mQDsgcDWA1m{rEd=Ww3!Pj#b#ifRq>&ty)oxsqR=yY>=F5{s z0MCP8JqZp=cfzIu8U%sz15wXg=yd^N3$`Ch^!SHK&f38ZtI(*xK$yQ5ZY`ZjcD@xu%g!Lne}smftEG=WDr9*`ZP2hf0h zo@Q9uhhGL??c8zj^%5>u{vh-t*A86%V+f**wk zJ)G3t2Z$U0O%Y+FDFy`-r)jl&?YkI&Ejg9X?Eqxe1v7zW=w&INw>y0_%OG~fsIa*V zzz0s1fEXFvO$@#gqr*xi>tVb!#mNR8eGK}coLR(09j-p8v%9-#*2ZaBI{*bda%#;M zytNi2@&I;`JjF?1F`g3eQ%(sP*g>3XKrDa^7AOaJq?1#Nhaj9LdHjLUA|8BjId_iF zDS8m3<+N#2LN|e5-Lyz>2X$$mbgi_b`kc`Fp&;^?wZv{hG<2@ zr-cpJv17C&QvAJH{S!d+x;kqtYcwlTuyLUX4UT7YC@wM(~N4cew+blc0r$o zXzcZZ!ij<719Jsc(SoX}oT+_+FE>-rut1id*PPYNksG4qhHyogz0&fb^k)ywkgW?9 zYtA>EZJ4Xr9Ie}`bouyT_3NTso1ly&U${}?7-Z*`=jgbpEggkpS1i^`tgG^>)Mx( zV+uDX$NAi(F=XA6=?vO{ByJ%LM)XwY^sA%#)d9)%d$!K&4T0x^EmK|78^F)4)<)G; z0o8bZupqP+*T^`g8`VY3_s^;u7V_?yFD#kQx6GHWo-ZuEtx@CysC`TekhsP$rXAJt zOW7Y%&8n-enerxzju*|DDx#)}(5@NNJptuHZdqvU)Y_Tc4FUCn8A_*ieONYYZVhM_ zOsj%Dp`PicS<{Yy55_2yLJ(^G8dk%)Qotoq(m z;S*CWq4tP*!>oGa^;H!MdgHi!F1tLMT^?#Zzvt|pxyqJkWy_4d_1D>@^JdF!mAq6N zQ5W6TCf{})yup0grf_rku}ImLF9?ZdTU5U)&^F$f*qOn#p}deP#7wORH{99~u{8cl zzZD#J%{G7uU@-v2pH&ym8*_Ql#i>P(9`J6n>14!CCJXoCc!FjM) z@ogt(e#ISNv1CZ`f?@zpK`~7Oi-r^^na5e}lf45Icay<$}gBKk0v_Cmxdgw|?+8~Kh* z6O^*(Z3yyH5S;}gZLyiX%K;eSz+gqX7`;oQcaY);M7&51de01=8jP&oFrS+rm#Z`d z3)wjn`s4cG!C>c1cGZ-5&bl=Uf7#mtO|bDN_8i|cXReN#tEV2DG1md6W!@NQn=dE{ zK6dW&nmcwWfO=kce4uUIraPc~&YttGCm z)ij%ASL-azdf7*M1>~3661WEZ&fmuG{Pg3i%=;2>rUXCSOAJZAg#$9~dVi7vT)>dx zPoxOG5$a|>WIV`{WuDXW9g7z{HV~|&Uy4T^68Igf7OrN%(aCadkfx1?M$C!;=g-pR zwBhJPIwXBT3f#b_Y22XjA!;Q049CD=7EU>M`GeS8$UXbxTz)5<9y^h}) z_By}O83#NJ=BID!lzsDasAFG*2=HLJLbG+jls8UIc#eBQ-WgLhps0p>!V<)4b;otV z)-!uf?U|CyX0QKJ1{K!@)HPC6JLP@n#S1S+YIjB~5By5s^rZr7`MzJ@Rub8TD95)Z zx3CDI&gmPY`o^zOcyIlRMN9wX{HA)aJ)0_JAFPMm)k;NEjq0jZh533-bFu7dgQYoN z_EEk9@|pMbl@$`5l?+JIyhk~VvLf`N-MAuTmfxb6$~+5vg9arZGPh0ba-MIJcxfh# z6gUKCjREkHq}~8O7=CHIlEN92hp$2}jPdS7CY`fP7(KQy`y2Qbj zfmcK-p z4nxH28Z@qThOsT9TgG;b?g&<%sXJ9St6LwJDh)dq@(P85sm(Ka^}vCq`f$@i*}C(^ zXN#vE`*G=b>xn(Ux~BT;0!K$mH-szyuI}Bs$cE-fNy}_@>wG~m7@vk*JWh#No}AS` zb%ii@mnm@b>njjY-}m%SsBy(6wBvh%)<>!8I~ECOin z&Sa!z zIwk?8$N(~m?8XZR^bz^cn^Z<(2|)45Y{n6jY@KX&7kE+}QA|OVrp8SwsT7rqiL773 z-x)yvZ$-d^7)pBR8Q5_}Y3!GYsR0BAcnt1rZ3LppTY-4fNZ95}Cnx!v zM(pSQ83QCtP#m z+u<$70oLYmQpC)e zq3=h{^>exRMRV^9sITdA$Dcd0UG!tBLx)4Xk@`m>mPcpxkD;q=G$-Je4!D`gwBbui zc3Gv26>=Tc9K0%pz<>?#2^)eFJ}2h&HaMNOI}VC29Er4jQQMo(dcD3BngaKkegVGH&L5RDMOmRAGQTT14O zNDvGX|%y9*z;E?56Zehvi_pMP_ruN4PNT$nwQT;*nEzEx2A!(B+%%RP30@HBo zEk2ERNcD<>uslvcGM#q|lUpS!#nyOEwjwVGtsse4>J@n*`E97o6Xu7rvaV)+1c|W4 zP!KaHfjAZ3X2T-O3G@l}U^{;mj^BD* zHVd{GocGt#ZkBd+IcWiPDiDi>-3a$O;D!Mn_@g%GjLBL!0d{n=D_|)>rRa9^2vi&1 zw_|uf48C`dga5{Wys;kV^EA$lq1|4l1ZU2Fi{;-3a6e&V>|ox8CTLZ#m;oD0BKcgd zkjMctLON#k$j zTYf9Q|9A4eQTbljsKf1_XAxOB<5frhY|c;-HB`(P*322IqlW5X?U%|XnI!KE0^>i% za|w0s7lcgG2s&%HeO|8p%Z`^iBDoLG%2N;b|BJjKu7WmSv1pQCu{7-@uI{up-y{2I Iy#(U_0d&06YXATM literal 0 HcmV?d00001 diff --git a/__pycache__/risk_manager.cpython-312.pyc b/__pycache__/risk_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ef8794431513b528e46a146badc07edcc2f37c7 GIT binary patch literal 10502 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 literal 0 HcmV?d00001 diff --git a/check_account_config.py b/check_account_config.py new file mode 100644 index 0000000..960437a --- /dev/null +++ b/check_account_config.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +DB에서 계좌번호 설정 확인 스크립트 +- 현재 최신 env_config에서 계좌번호 관련 필드 조회 +- 모의/실전 구분 및 실제 읽히는 값 확인 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def main(): + db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) + env_data = db.get_latest_env() + db.close() + + if not env_data or not env_data.get("snapshot"): + print("❌ env_config에 데이터가 없습니다.") + return + + snapshot = env_data["snapshot"] + print(f"📊 최신 env_config (id={env_data['id']}, created_at={env_data['created_at']})\n") + + # 모의 여부 + kis_mock = snapshot.get("KIS_MOCK", "").lower() + is_mock = kis_mock in ("true", "1", "yes") + print(f"🔹 KIS_MOCK = '{kis_mock}' → 모의투자: {is_mock}\n") + + # 실전 계좌 + print("📌 실전 계좌 (KIS_MOCK=false 시 사용):") + acc_no_real = snapshot.get("KIS_ACCOUNT_NO", "").strip() + acc_code_real = snapshot.get("KIS_ACCOUNT_CODE", "").strip() or "01" + print(f" KIS_ACCOUNT_NO = '{acc_no_real}' ({len(acc_no_real)}자리)") + print(f" KIS_ACCOUNT_CODE = '{acc_code_real}' ({len(acc_code_real)}자리)") + if acc_no_real: + digits_no = "".join(c for c in acc_no_real if c.isdigit()) + print(f" → 숫자만 추출: '{digits_no}' ({len(digits_no)}자리)") + if len(digits_no) >= 10: + print(f" → CANO: '{digits_no[:8]}', ACNT_PRDT_CD: '{digits_no[8:10]}' ✅") + elif len(digits_no) == 8: + print(f" → CANO: '{digits_no}', ACNT_PRDT_CD: '{acc_code_real.zfill(2)[:2]}' ✅") + else: + print(f" → ⚠️ 8자리 미만: CANO='{digits_no.zfill(8)[:8]}', ACNT_PRDT_CD='{acc_code_real.zfill(2)[:2]}' (부족한 자리 0으로 채움)") + else: + print(" → ❌ 값이 비어있음!") + + print() + + # 모의 계좌 + print("📌 모의 계좌 (KIS_MOCK=true 시 사용):") + acc_no_mock = snapshot.get("KIS_ACCOUNT_NO_MOCK", "").strip() + acc_code_mock = snapshot.get("KIS_ACCOUNT_CODE_MOCK", "").strip() or "01" + print(f" KIS_ACCOUNT_NO_MOCK = '{acc_no_mock}' ({len(acc_no_mock)}자리)") + print(f" KIS_ACCOUNT_CODE_MOCK = '{acc_code_mock}' ({len(acc_code_mock)}자리)") + if acc_no_mock: + digits_no = "".join(c for c in acc_no_mock if c.isdigit()) + print(f" → 숫자만 추출: '{digits_no}' ({len(digits_no)}자리)") + if len(digits_no) >= 10: + print(f" → CANO: '{digits_no[:8]}', ACNT_PRDT_CD: '{digits_no[8:10]}' ✅") + elif len(digits_no) == 8: + print(f" → CANO: '{digits_no}', ACNT_PRDT_CD: '{acc_code_mock.zfill(2)[:2]}' ✅") + else: + print(f" → ⚠️ 8자리 미만: CANO='{digits_no.zfill(8)[:8]}', ACNT_PRDT_CD='{acc_code_mock.zfill(2)[:2]}' (부족한 자리 0으로 채움)") + else: + print(" → ❌ 값이 비어있음! (fallback: 실전 계좌 사용)") + + print() + + # 실제 사용될 계좌 + print("🎯 실제 사용될 계좌:") + if is_mock: + final_no = acc_no_mock or acc_no_real + final_code = acc_code_mock if acc_no_mock else acc_code_real + print(f" 모의투자 모드 → KIS_ACCOUNT_NO_MOCK='{acc_no_mock}' 또는 KIS_ACCOUNT_NO='{acc_no_real}'") + else: + final_no = acc_no_real + final_code = acc_code_real + print(f" 실전투자 모드 → KIS_ACCOUNT_NO='{acc_no_real}'") + + if final_no: + digits_no = "".join(c for c in final_no if c.isdigit()) + if len(digits_no) >= 10: + cano = digits_no[:8] + acnt = digits_no[8:10] + elif len(digits_no) == 8: + cano = digits_no + acnt = final_code.zfill(2)[:2] + else: + cano = digits_no.zfill(8)[:8] + acnt = final_code.zfill(2)[:2] + + if len(cano) == 8 and len(acnt) == 2: + print(f" ✅ 최종: CANO={cano}, ACNT_PRDT_CD={acnt}") + else: + print(f" ❌ 최종: CANO={cano}({len(cano)}자리), ACNT_PRDT_CD={acnt}({len(acnt)}자리) → OPSQ2000 발생 가능!") + else: + print(" ❌ 계좌번호가 비어있음 → OPSQ2000 발생!") + +if __name__ == "__main__": + main() diff --git a/check_db.py b/check_db.py new file mode 100644 index 0000000..0ddae4d --- /dev/null +++ b/check_db.py @@ -0,0 +1,28 @@ +import sqlite3 +import os + +# 데이터베이스 파일 경로 +db_path = os.path.join(os.path.dirname(__file__), 'quant_bot.db') + +# 연결 생성 +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 테이블 목록 조회 +cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") +tables = cursor.fetchall() + +print("Tables in database:") +for table in tables: + print(f" {table[0]}") + +# 각 테이블의 구조 조회 +for table in tables: + table_name = table[0] + print(f"\nStructure of {table_name}:") + cursor.execute(f"PRAGMA table_info({table_name});") + columns = cursor.fetchall() + for column in columns: + print(f" {column[1]} ({column[2]}) - nullable: {column[3]}, primary key: {column[4]}") + +conn.close() \ No newline at end of file diff --git a/copy_env_row_to_latest.py b/copy_env_row_to_latest.py new file mode 100644 index 0000000..0dab11e --- /dev/null +++ b/copy_env_row_to_latest.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +env_config에서 지정한 id 행을 그대로 복사해 새 행으로 INSERT. +→ 봇은 항상 id 최대(최신) 행만 쓰므로, 4번 내용이 곧 '최신 설정'이 됨. + +사용: python copy_env_row_to_latest.py [소스_id] + 소스_id 기본값: 4 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB, ENV_CONFIG_KEYS + +def main(): + source_id = int(sys.argv[1]) if len(sys.argv) > 1 else 4 + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + row = db.conn.execute("SELECT * FROM env_config WHERE id = ?", (source_id,)).fetchone() + if not row: + print(f"id={source_id} 행이 없습니다.") + db.close() + return + + # id=4 내용으로 새 스냅샷 생성 (created_at만 현재 시각) + import datetime + snapshot = {k: (row[k] if row[k] is not None else "") for k in ENV_CONFIG_KEYS if k in row.keys()} + env_id = db.insert_env_snapshot(snapshot) + db.close() + + if env_id: + print(f"✅ id={source_id} 내용을 새 행(id={env_id})으로 복사했습니다. 이제 최신 설정입니다.") + else: + print("❌ INSERT 실패") + +if __name__ == "__main__": + main() diff --git a/database.py b/database.py new file mode 100644 index 0000000..14e69a8 --- /dev/null +++ b/database.py @@ -0,0 +1,872 @@ +""" +트레이딩 봇 데이터베이스 관리 모듈 +- SQLite 기반 데이터 무결성 보장 (JSON 대비 재시작 안전성 향상) +- 활성 트레이딩 관리 (active_trades) +- 매매 히스토리 관리 (trade_history) +""" +import json +import sqlite3 +import datetime +import logging +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger("TradeDB") + +# env_config 테이블 컬럼 (키 하나당 컬럼 하나, 추가/삭제 시 여기와 CREATE TABLE만 수정) +ENV_CONFIG_KEYS = ( + "STOP_LOSS_PCT", "SHOULDER_CUT_PCT", "STOP_ATR_MULTIPLIER_TAIL", "TARGET_ATR_MULTIPLIER_TAIL", + "MAX_POSITION_PCT", "USE_SLOT_CAP", "SLOT_CAP_PCT", "MAX_STOCKS", + "USE_KELLY", "RISK_PCT_PER_TRADE", "MIN_POSITION_AMOUNT", + "USE_RISK_CHECK", "DAILY_STOP_LOSS_PCT", "CONSECUTIVE_LOSS_LIMIT", + "USE_BAN_SYSTEM", "BAN_HOURS", "USE_STOCK_FILTER", "RSI_OVERHEAT_THRESHOLD", + "MIN_RECOVERY_RATIO", "MAX_RECOVERY_RATIO", + "USE_TWAP", "TWAP_MIN_SPLIT", "TWAP_MAX_SPLIT", "TWAP_MIN_DELAY", "TWAP_MAX_DELAY", + "USE_ML_SIGNAL", "ML_MIN_PROBABILITY", "USE_NEWS_ANALYSIS", "NEWS_ANALYSIS_HOUR", "NEWS_MAX_COUNT", + "USE_QUICK_PROFIT_PROTECTION", "HIGH_PRICE_CHASE_THRESHOLD", "MAX_DAILY_CHANGE_PCT", + "MA20_MAX_ABOVE_PCT", "VOLUME_AVG_MULTIPLIER", "CANDLE_OPEN_PRICE_BUFFER", + "INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "SIZE_CLASS_LARGE_MIN", "SIZE_CLASS_MID_MIN", + "USE_RANDOM_SPLIT", "FORCE_MARKET_OPEN", "TOTAL_DEPOSIT", + # POP/LOCK·금액 손절 관련 추가 키 + "ROUND_TRIP_COST_PCT", "POP_NET_PCT", "LOCK_NET_PCT", "MAX_LOSS_PER_TRADE_KRW", + # 한투 API 관련 키 추가 (실전/모의 계좌 분리) + "KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", + "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK", + "KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", # 실전 계좌 (KIS_MOCK=false 시 사용) + "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK", # 모의 계좌 (KIS_MOCK=true 시 사용) + "KIS_MOCK", + # 단타 봇 전용 키 + "TAKE_PROFIT_PCT", "MIN_DROP_RATE", "MIN_RECOVERY_RATIO_SHORT", + # 늘림목 봇 전용 키 + "MAX_PER", "MAX_PEG", "MIN_GROWTH_PCT", "DCA_INTERVALS", "DCA_AMOUNTS", + # Mattermost 및 AI 리포트 관련 키 + "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY", + # 봇별 Mattermost 채널 구분용 키 + "KIS_SHORT_MM_CHANNEL", "KIS_LONG_MM_CHANNEL", +) + + +class TradeDB: + """ + 트레이딩 봇용 SQLite 데이터베이스 관리 클래스 + """ + def __init__(self, db_path="quant_bot.db"): + """ + Args: + db_path: SQLite DB 파일 경로 (기본: quant_bot.db) + """ + self.db_path = db_path + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row # 딕셔너리처럼 접근 가능 + self._create_tables() + logger.info(f"✅ TradeDB 초기화 완료: {db_path}") + + def _create_tables(self): + """DB 테이블 생성 (없을 경우)""" + with self.conn: + # 1. 활성 트레이딩 테이블 (현재 보유 중이거나 매수 중인 종목) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS active_trades ( + code TEXT PRIMARY KEY, -- 종목코드 + name TEXT NOT NULL, -- 종목명 + strategy TEXT, -- 매매 전략 (TAIL_CATCH_3M 등) + + -- [가격 정보] + avg_buy_price REAL NOT NULL, -- 평단가 + current_price REAL, -- 현재가 (업데이트용) + stop_price REAL, -- 손절가 + target_price REAL, -- 목표가 + max_price REAL, -- 최고가 (트레일링 스탑용) + atr_entry REAL, -- 진입 시 ATR 변동성 + + -- [수량 및 진행 상태 (분할매수용)] + target_qty INTEGER NOT NULL, -- 목표 매수 수량 + current_qty INTEGER NOT NULL,-- 현재 체결 수량 + total_invested REAL, -- 총 투입 금액 (수수료 제외) + + -- [상태 관리] + status TEXT NOT NULL, -- BUYING(매수중), HOLDING(보유중), SELLING(매도중) + buy_date TEXT NOT NULL, -- 첫 매수 시작 시간 + updated_at TEXT NOT NULL, -- 마지막 업데이트 시간 + size_class TEXT -- 대/중/소형 (매수 시점) + ) + """) + + # 2. 매매 기록 테이블 (손익 분석 & 켈리 공식용) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS trade_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT NOT NULL, + strategy TEXT, + buy_price REAL NOT NULL, -- 평단가 + sell_price REAL NOT NULL, -- 매도가 + qty INTEGER NOT NULL, -- 수량 + profit_rate REAL NOT NULL, -- 수익률 (%) + realized_pnl REAL NOT NULL, -- 실현 손익금 (원) + hold_minutes INTEGER, -- 보유 시간 (분) + buy_date TEXT, -- 매수 시작 시간 + sell_date TEXT NOT NULL, -- 매도 완료 시간 + sell_reason TEXT, -- 매도 사유 + env_snapshot TEXT, -- 매도 시점 env (JSON) + size_class TEXT -- 대/중/소형 (변동성 구간) + ) + """) + + # 3. 일일 손익 요약 테이블 (대시보드용) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS daily_summary ( + date TEXT PRIMARY KEY, -- 날짜 (YYYY-MM-DD) + start_asset REAL, -- 시작 자산 + end_asset REAL, -- 종료 자산 + total_trades INTEGER, -- 총 매매 횟수 + win_trades INTEGER, -- 익절 횟수 + total_pnl REAL, -- 총 손익 + win_rate REAL -- 승률 (%) + ) + """) + + # 4. 주문·체결 보강 테이블 (kt00007 / ka10076) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS order_execution_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, -- 'kt00007' or 'ka10076' + ord_no TEXT, + stk_cd TEXT, + stk_nm TEXT, + trde_tp TEXT, -- 매매구분 + ord_qty TEXT, + ord_uv TEXT, + cntr_qty TEXT, + cntr_uv TEXT, + ord_tm TEXT, + cnfm_tm TEXT, + sell_tp TEXT, + ord_dt TEXT, + raw_json TEXT, + fetched_at TEXT NOT NULL + ) + """) + + # 5. 매수 후보군 테이블 (target_universe 대체) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS target_candidates ( + code TEXT PRIMARY KEY, -- 종목코드 + name TEXT NOT NULL, -- 종목명 + score REAL NOT NULL, -- 개미털기 점수 (높을수록 좋음) + price REAL NOT NULL, -- 현재가 + scan_time TEXT NOT NULL, -- 스캔 시간 + updated_at TEXT NOT NULL -- 마지막 업데이트 + ) + """) + + # 6. 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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + {cols} + ) + """) + + self._migrate_add_columns() + self._migrate_env_config_to_columns() + logger.info("📊 DB 테이블 생성/확인 완료") + + def _migrate_add_columns(self): + """기존 DB에 누락된 컬럼 추가 (한 번만)""" + try: + cur = self.conn.execute("PRAGMA table_info(trade_history)") + cols = [row[1] for row in cur.fetchall()] + if "env_snapshot" not in cols: + self.conn.execute("ALTER TABLE trade_history ADD COLUMN env_snapshot TEXT") + logger.info("📌 trade_history.env_snapshot 컬럼 추가") + if "size_class" not in cols: + self.conn.execute("ALTER TABLE trade_history ADD COLUMN size_class TEXT") + logger.info("📌 trade_history.size_class 컬럼 추가") + except Exception as e: + logger.debug(f"migrate trade_history: {e}") + try: + cur = self.conn.execute("PRAGMA table_info(active_trades)") + cols = [row[1] for row in cur.fetchall()] + if "size_class" not in cols: + self.conn.execute("ALTER TABLE active_trades ADD COLUMN size_class TEXT") + logger.info("📌 active_trades.size_class 컬럼 추가") + except Exception as e: + logger.debug(f"migrate active_trades: {e}") + # env_config 테이블에 ENV_CONFIG_KEYS에 정의된 컬럼 누락 시 추가 + try: + cur = self.conn.execute("PRAGMA table_info(env_config)") + cols = [row[1] for row in cur.fetchall()] + for key in ENV_CONFIG_KEYS: + if key not in cols: + self.conn.execute(f'ALTER TABLE env_config ADD COLUMN "{key}" TEXT') + logger.info(f"📌 env_config.{key} 컬럼 추가") + except Exception as e: + logger.debug(f"migrate env_config columns: {e}") + + def _migrate_env_config_to_columns(self): + """env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전""" + try: + cur = self.conn.execute("PRAGMA table_info(env_config)") + cols = [row[1] for row in cur.fetchall()] + if "snapshot_json" not in cols: + return + # 기존 데이터 백업 후 새 테이블로 이전 + rows = self.conn.execute("SELECT id, created_at, snapshot_json FROM env_config ORDER BY id").fetchall() + col_defs = ", ".join([f'"{k}" TEXT' for k in ENV_CONFIG_KEYS]) + self.conn.execute(f""" + CREATE TABLE IF NOT EXISTS env_config_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + {col_defs} + ) + """) + key_list = ", ".join(f'"{k}"' for k in ENV_CONFIG_KEYS) + placeholders = ", ".join(["?"] * (1 + len(ENV_CONFIG_KEYS))) + for row in rows: + snap = json.loads(row["snapshot_json"]) if row["snapshot_json"] else {} + vals = [row["created_at"]] + [snap.get(k) for k in ENV_CONFIG_KEYS] + self.conn.execute( + f"INSERT INTO env_config_new (created_at, {key_list}) VALUES ({placeholders})", + vals, + ) + self.conn.execute("DROP TABLE env_config") + self.conn.execute("ALTER TABLE env_config_new RENAME TO env_config") + self.conn.commit() + logger.info("📌 env_config: snapshot_json -> 컬럼 스키마 마이그레이션 완료") + except Exception as e: + logger.debug(f"migrate env_config: {e}") + + # ============================================================ + # [CRUD] Active Trades (활성 트레이딩 관리) + # ============================================================ + + def upsert_trade(self, trade_data: Dict): + """ + 신규 매수하거나 정보 업데이트 (평단가, 수량 등) + + Args: + trade_data: 트레이드 정보 딕셔너리 + 필수: code, name, avg_buy_price, target_qty, current_qty, status + 선택: strategy, stop_price, target_price, max_price, atr_entry, total_invested + """ + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 기본값 설정 + code = trade_data.get('code') + if not code: + logger.error("종목코드 누락: upsert 실패") + return False + + size_class = trade_data.get('size_class') + sql = """ + INSERT INTO active_trades ( + code, name, strategy, avg_buy_price, current_price, stop_price, target_price, + max_price, atr_entry, target_qty, current_qty, total_invested, + status, buy_date, updated_at, size_class + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + avg_buy_price = excluded.avg_buy_price, + current_price = excluded.current_price, + stop_price = COALESCE(excluded.stop_price, active_trades.stop_price), + target_price = COALESCE(excluded.target_price, active_trades.target_price), + atr_entry = COALESCE(excluded.atr_entry, active_trades.atr_entry), + current_qty = excluded.current_qty, + total_invested = excluded.total_invested, + max_price = MAX(active_trades.max_price, excluded.max_price), + status = excluded.status, + updated_at = excluded.updated_at, + size_class = COALESCE(excluded.size_class, active_trades.size_class) + """ + params = ( + code, + trade_data.get('name', 'Unknown'), + trade_data.get('strategy', 'MANUAL'), + trade_data.get('avg_buy_price') or trade_data.get('buy_price', 0), + trade_data.get('current_price', 0), + trade_data.get('stop_price', 0), + trade_data.get('target_price', 0), + trade_data.get('max_price', trade_data.get('buy_price', 0)), + trade_data.get('atr_at_entry') or trade_data.get('atr_entry', 0), + trade_data.get('target_qty', trade_data.get('qty', 0)), + trade_data.get('current_qty') or trade_data.get('qty', 0), + trade_data.get('total_invested', 0), + trade_data.get('status', 'HOLDING'), + trade_data.get('buy_date', now), + now, + size_class + ) + + try: + with self.conn: + self.conn.execute(sql, params) + return True + except Exception as e: + logger.error(f"❌ upsert_trade 실패 ({code}): {e}") + return False + + def get_active_trades(self): + """ + 활성 트레이딩 목록 조회 (봇 재시작 시 사용) + + Returns: + {종목코드: {trade_info}} 형태의 딕셔너리 + """ + try: + cursor = self.conn.execute("SELECT * FROM active_trades") + rows = cursor.fetchall() + + # 기존 JSON 포맷과 호환되도록 딕셔너리 변환 + result = {} + for row in rows: + code = row['code'] + result[code] = { + 'code': code, + 'name': row['name'], + 'strategy': row['strategy'], + 'buy_price': row['avg_buy_price'], # JSON 호환 + 'avg_buy_price': row['avg_buy_price'], + 'current_price': row['current_price'], + 'stop_price': row['stop_price'], + 'target_price': row['target_price'], + 'max_price': row['max_price'], + 'atr_at_entry': row['atr_entry'], + 'qty': row['current_qty'], # JSON 호환 + 'target_qty': row['target_qty'], + 'current_qty': row['current_qty'], + 'total_invested': row['total_invested'], + 'status': row['status'], + 'buy_date': row['buy_date'], + 'updated_at': row['updated_at'], + 'size_class': row['size_class'] if 'size_class' in row.keys() else None, + } + + logger.debug(f"📂 활성 트레이드 로드: {len(result)}개") + return result + + except Exception as e: + logger.error(f"❌ get_active_trades 실패: {e}") + return {} + + def update_current_price(self, code: str, current_price: float): + """현재가 업데이트 (매도 판단용)""" + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + with self.conn: + self.conn.execute( + "UPDATE active_trades SET current_price=?, updated_at=? WHERE code=?", + (current_price, now, code) + ) + except Exception as e: + logger.error(f"❌ 현재가 업데이트 실패 ({code}): {e}") + + def update_max_price(self, code: str, new_max_price: float): + """최고가 갱신 (트레일링 스탑용)""" + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + with self.conn: + # 기존 max_price보다 클 때만 업데이트 + self.conn.execute( + """UPDATE active_trades + SET max_price = MAX(max_price, ?), updated_at = ? + WHERE code = ?""", + (new_max_price, now, code) + ) + except Exception as e: + logger.error(f"❌ 최고가 갱신 실패 ({code}): {e}") + + def close_trade( + self, + code: str, + sell_price: float, + sell_reason: str = "", + env_snapshot: str = None, + size_class: str = None, + ): + """ + 매도 완료 처리: active_trades 삭제 -> trade_history 이동 (INSERT만, env 스냅샷 포함) + + Args: + code: 종목코드 + sell_price: 매도가 + sell_reason: 매도 사유 + env_snapshot: 매도 시점 env JSON (백테스트/대시보드용) + size_class: 대/중/소형 (매수 시점 저장값) + """ + try: + # 1. 활성 트레이드 정보 조회 + cursor = self.conn.execute("SELECT * FROM active_trades WHERE code=?", (code,)) + trade = cursor.fetchone() + + if not trade: + logger.warning(f"⚠️ close_trade: {code} 종목이 active_trades에 없음") + return False + + # 2. 손익 계산 + buy_price = trade['avg_buy_price'] + qty = trade['current_qty'] + profit_rate = ((sell_price - buy_price) / buy_price) * 100 if buy_price > 0 else 0 + realized_pnl = (sell_price - buy_price) * qty + + # 3. 보유 시간 계산 + buy_time = datetime.datetime.strptime(trade['buy_date'], '%Y-%m-%d %H:%M:%S') + sell_time = datetime.datetime.now() + hold_minutes = int((sell_time - buy_time).total_seconds() / 60) + + # size_class는 active_trades에 있으면 그대로 사용 + if size_class is None and 'size_class' in trade.keys() and trade['size_class']: + size_class = trade['size_class'] + + # 4. trade_history에 저장 (env_snapshot, size_class 포함 INSERT) + with self.conn: + self.conn.execute(""" + INSERT INTO trade_history ( + code, name, strategy, buy_price, sell_price, qty, + profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, + env_snapshot, size_class + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + trade['code'], + trade['name'], + trade['strategy'], + buy_price, + sell_price, + qty, + profit_rate, + realized_pnl, + hold_minutes, + trade['buy_date'], + sell_time.strftime('%Y-%m-%d %H:%M:%S'), + sell_reason, + env_snapshot, + size_class, + )) + + # 5. active_trades에서 삭제 + self.conn.execute("DELETE FROM active_trades WHERE code=?", (code,)) + + logger.info(f"✅ [{trade['name']}] 매매 종료: 수익률 {profit_rate:.2f}% ({realized_pnl:+,.0f}원)") + return True + + except Exception as e: + logger.error(f"❌ close_trade 실패 ({code}): {e}") + return False + + def delete_active_trade(self, code: str): + """활성 트레이드 삭제 (긴급 정리용)""" + try: + with self.conn: + self.conn.execute("DELETE FROM active_trades WHERE code=?", (code,)) + logger.info(f"🗑️ active_trade 삭제: {code}") + return True + except Exception as e: + logger.error(f"❌ 삭제 실패 ({code}): {e}") + return False + + # ============================================================ + # [보강] 주문·체결 이력 (kt00007 / ka10076) + # ============================================================ + + def insert_order_execution( + self, source: str, row: dict, ord_dt: str = None, sell_tp: str = None, raw_json: str = None + ): + """주문·체결 1건 INSERT (보강용, 이력만 쌓음)""" + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + self.conn.execute(""" + INSERT INTO order_execution_history ( + source, ord_no, stk_cd, stk_nm, trde_tp, ord_qty, ord_uv, + cntr_qty, cntr_uv, ord_tm, cnfm_tm, sell_tp, ord_dt, raw_json, fetched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + source, + row.get('ord_no') or row.get('orig_ord_no'), + row.get('stk_cd', ''), + row.get('stk_nm', ''), + row.get('trde_tp', ''), + str(row.get('ord_qty', '') or row.get('cntr_qty', '')), + str(row.get('ord_uv', '') or row.get('ord_pric', '') or row.get('cntr_uv', '')), + str(row.get('cntr_qty', '') or row.get('cnfm_qty', '')), + str(row.get('cntr_uv', '') or row.get('cntr_pric', '')), + row.get('ord_tm', ''), + row.get('cnfm_tm', ''), + sell_tp or '', + ord_dt or '', + raw_json, + now, + )) + self.conn.commit() + return True + except Exception as e: + logger.debug(f"insert_order_execution: {e}") + return False + + # ============================================================ + # [분석] 켈리 공식 및 통계 계산 + # ============================================================ + + def calculate_half_kelly(self, recent_days: int = 30) -> float: + """ + 하프 켈리 공식 계산 (과거 매매 기록 기반) + + Args: + recent_days: 최근 N일 데이터만 사용 + + Returns: + 하프 켈리 비율 (0.0 ~ 1.0) + 예: 0.15 리턴 -> "예수금의 15%씩 배팅하는 게 최적" + """ + try: + # 최근 N일 데이터 조회 + cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=recent_days)).strftime('%Y-%m-%d') + + cursor = self.conn.execute( + "SELECT profit_rate FROM trade_history WHERE sell_date >= ? ORDER BY sell_date DESC", + (cutoff_date,) + ) + rows = cursor.fetchall() + + if len(rows) < 20: # 최소 20건 이상 필요 + logger.warning(f"⚠️ 켈리 공식: 데이터 부족 ({len(rows)}건) -> 기본값 10% 리턴") + return 0.10 + + # 승률 계산 + wins = [r['profit_rate'] for r in rows if r['profit_rate'] > 0] + losses = [r['profit_rate'] for r in rows if r['profit_rate'] <= 0] + + total_count = len(rows) + win_count = len(wins) + win_rate = win_count / total_count + loss_rate = 1.0 - win_rate + + # 손익비 계산 (평균 수익 / 평균 손실) + if not wins or not losses: + logger.warning("⚠️ 켈리 공식: 승 또는 패만 있음 -> 기본값 10%") + return 0.10 + + avg_win = sum(wins) / len(wins) + avg_loss = abs(sum(losses) / len(losses)) + + if avg_loss == 0: + return 0.50 # 손실이 0이면 최대치 + + odds = avg_win / avg_loss + + # 켈리 공식: f = (p * b - q) / b + # p=승률, b=손익비, q=패율 + kelly_fraction = ((win_rate * odds) - loss_rate) / odds + + # 하프 켈리 (안전성 확보) + half_kelly = kelly_fraction * 0.5 + + # 음수면 0 리턴 (통계적으로 지는 구조) + final_kelly = max(0.0, min(half_kelly, 0.5)) # 최대 50%로 제한 + + logger.info( + f"📊 [켈리 분석] 승률:{win_rate*100:.1f}% | 손익비:{odds:.2f} | " + f"켈리:{kelly_fraction*100:.1f}% | 하프켈리:{final_kelly*100:.1f}%" + ) + + return final_kelly + + except Exception as e: + logger.error(f"❌ 켈리 계산 실패: {e}") + return 0.10 + + def get_recent_performance(self, days: int = 7) -> Tuple[float, int, int]: + """ + 최근 N일 성과 조회 + + Returns: + (총손익, 익절횟수, 손절횟수) + """ + try: + cutoff = (datetime.datetime.now() - datetime.timedelta(days=days)).strftime('%Y-%m-%d') + + cursor = self.conn.execute( + "SELECT realized_pnl FROM trade_history WHERE sell_date >= ?", + (cutoff,) + ) + rows = cursor.fetchall() + + total_pnl = sum([r['realized_pnl'] for r in rows]) + wins = len([r for r in rows if r['realized_pnl'] > 0]) + losses = len([r for r in rows if r['realized_pnl'] <= 0]) + + return total_pnl, wins, losses + + except Exception as e: + logger.error(f"❌ 성과 조회 실패: {e}") + return 0.0, 0, 0 + + def get_trade_stats(self) -> Dict: + """전체 매매 통계""" + try: + cursor = self.conn.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins, + AVG(profit_rate) as avg_profit_rate, + SUM(realized_pnl) as total_pnl + FROM trade_history + """) + row = cursor.fetchone() + + return { + 'total_trades': row['total'] or 0, + 'win_trades': row['wins'] or 0, + 'win_rate': (row['wins'] / row['total'] * 100) if row['total'] > 0 else 0, + 'avg_profit_rate': row['avg_profit_rate'] or 0, + 'total_pnl': row['total_pnl'] or 0 + } + except Exception as e: + logger.error(f"❌ 통계 조회 실패: {e}") + return {} + + # ============================================================ + # [유틸] JSON 마이그레이션 + # ============================================================ + + def migrate_from_json(self, json_data: Dict): + """ + 기존 JSON 포트폴리오를 DB로 마이그레이션 + + Args: + json_data: portfolio.json 내용 (딕셔너리) + """ + count = 0 + for code, info in json_data.items(): + trade_data = info.copy() + trade_data['code'] = code + + # 필드 매핑 (JSON -> DB) + if 'target_qty' not in trade_data: + trade_data['target_qty'] = info.get('qty', 0) + if 'current_qty' not in trade_data: + trade_data['current_qty'] = info.get('qty', 0) + if 'total_invested' not in trade_data: + trade_data['total_invested'] = info.get('buy_price', 0) * info.get('qty', 0) + if 'status' not in trade_data: + trade_data['status'] = 'HOLDING' + + if self.upsert_trade(trade_data): + count += 1 + + logger.info(f"✅ JSON -> DB 마이그레이션 완료: {count}개 종목") + return count + + # ============================================================ + # [CRUD] Target Candidates (매수 후보군 관리) + # ============================================================ + + def update_target_candidates(self, candidates: List[Dict]): + """ + 매수 후보군 업데이트 (5분마다 호출) + + Args: + candidates: [{'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000}, ...] + """ + try: + scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 기존 데이터 전체 삭제 (5분마다 새로 갱신) + with self.conn: + self.conn.execute("DELETE FROM target_candidates") + + # 새 후보군 삽입 + for item in candidates: + self.conn.execute(""" + INSERT INTO target_candidates (code, name, score, price, scan_time, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, ( + item['code'], + item['name'], + item['score'], + item['price'], + scan_time, + scan_time + )) + + logger.info(f"✅ 매수 후보군 DB 저장: {len(candidates)}개") + return True + + except Exception as e: + logger.error(f"❌ 후보군 저장 실패: {e}") + return False + + def add_target_candidate(self, candidate: Dict): + """ + 매수 후보군 개별 추가 (통과 즉시 저장용, UPSERT 방식) + - 500개 스캔 시 시간이 오래 걸려서 통과하는 즉시 DB에 저장 + + Args: + candidate: {'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000, ...} + """ + try: + scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + with self.conn: + # UPSERT: 있으면 업데이트, 없으면 삽입 + self.conn.execute(""" + INSERT INTO target_candidates (code, name, score, price, scan_time, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + name = excluded.name, + score = excluded.score, + price = excluded.price, + scan_time = excluded.scan_time, + updated_at = excluded.updated_at + """, ( + candidate['code'], + candidate.get('name', ''), + candidate.get('score', 0), + candidate.get('price', 0), + scan_time, + scan_time + )) + + return True + + except Exception as e: + logger.debug(f"후보 개별 저장 실패({candidate.get('code', '')}): {e}") + return False + + def get_target_candidates(self) -> List[Dict]: + """ + 매수 후보군 조회 (점수 순) + + Returns: + [{'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000}, ...] + """ + try: + cursor = self.conn.execute(""" + SELECT code, name, score, price, scan_time + FROM target_candidates + ORDER BY score DESC, price ASC + """) + rows = cursor.fetchall() + + result = [] + for row in rows: + result.append({ + 'code': row['code'], + 'name': row['name'], + 'score': row['score'], + 'price': row['price'], + 'scan_time': row['scan_time'] + }) + + return result + + except Exception as e: + logger.error(f"❌ 후보군 조회 실패: {e}") + return [] + + def get_trades_by_date(self, date_str: str) -> List[Dict]: + """ + 특정 날짜의 매매 기록 조회 + + Args: + date_str: 날짜 (YYYYMMDD) + + Returns: + 매매 기록 리스트 + """ + try: + # YYYYMMDD -> YYYY-MM-DD 변환 + date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}" + + cursor = self.conn.execute(""" + SELECT * FROM trade_history + WHERE DATE(sell_date) = ? + ORDER BY sell_date DESC + """, (date_formatted,)) + + rows = cursor.fetchall() + + result = [] + for row in rows: + result.append({ + 'id': row['id'], + 'code': row['code'], + 'name': row['name'], + 'strategy': row['strategy'], + 'buy_price': row['buy_price'], + 'sell_price': row['sell_price'], + 'qty': row['qty'], + 'profit_rate': row['profit_rate'], + 'realized_pnl': row['realized_pnl'], + 'hold_minutes': row['hold_minutes'], + 'buy_date': row['buy_date'], + 'sell_date': row['sell_date'], + 'sell_reason': row['sell_reason'] + }) + + return result + + except Exception as e: + logger.error(f"❌ 날짜별 조회 실패: {e}") + return [] + + # ============================================================ + # [env_config] 관리자용 env (INSERT만, 최신 1건 = 살아있는 값) + # ============================================================ + + def insert_env_snapshot(self, snapshot) -> Optional[int]: + """ + env 설정 INSERT (UPDATE 없음). 관리자가 설정 변경할 때 호출. + snapshot: dict 또는 JSON 문자열. 키는 ENV_CONFIG_KEYS에 있는 것만 저장. + Returns: + 새 행 id, 실패 시 None + """ + try: + if isinstance(snapshot, str): + snapshot = json.loads(snapshot) if snapshot else {} + if not isinstance(snapshot, dict): + return None + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + key_list = ", ".join(f'"{k}"' for k in ENV_CONFIG_KEYS) + placeholders = ", ".join(["?"] * (1 + len(ENV_CONFIG_KEYS))) + vals = [now] + [snapshot.get(k) for k in ENV_CONFIG_KEYS] + cur = self.conn.execute( + f"INSERT INTO env_config (created_at, {key_list}) VALUES ({placeholders})", + vals, + ) + self.conn.commit() + return cur.lastrowid + except Exception as e: + logger.error(f"❌ env_config INSERT 실패: {e}") + return None + + def get_latest_env(self) -> Optional[Dict]: + """ + 살아있는 최신 env 1건 조회 (ORDER BY id DESC LIMIT 1). + Returns: + {"id": int, "created_at": str, "snapshot": dict} 또는 없으면 None + """ + try: + row = self.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + if not row: + return None + snapshot = { + k: (row[k] if row[k] is not None else "") + for k in ENV_CONFIG_KEYS + if k in row.keys() + } + return { + "id": row["id"], + "created_at": row["created_at"], + "snapshot": snapshot, + } + except Exception as e: + logger.error(f"❌ env_config 최신 조회 실패: {e}") + return None + + def close(self): + """DB 연결 종료""" + if self.conn: + self.conn.close() + logger.info("🔒 DB 연결 종료") diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..4698dcb --- /dev/null +++ b/init_db.py @@ -0,0 +1,78 @@ +""" +KIS 봇용 DB 초기화 스크립트 +- DB 파일 생성 및 테이블 생성 +- 기본 env_config 삽입 (새 스냅샷 행 1개 INSERT) + +실행 시점: 자동 실행 없음. 최초 1회 또는 DB 새로 쓸 때만 수동 실행. + 예: python kis_bot/init_db.py +- 실행할 때마다 env_config에 새 행이 추가됨(INSERT만, 기존 행 수정 안 함). +- 봇은 항상 '가장 최신 행(id 최대)'만 사용함. +- 모의/실전 계좌 둘 다 넣으려면 아래 default_env에 KIS_ACCOUNT_NO(실전), KIS_ACCOUNT_NO_MOCK(모의) 값을 채워두고 실행하면 됨. +""" +import os +import sys +from pathlib import Path + +# 현재 디렉토리를 경로에 추가 +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def init_database(): + """DB 초기화 및 기본 설정 삽입""" + db_path = SCRIPT_DIR / "quant_bot.db" + + print(f"📊 DB 초기화 시작: {db_path}") + + # DB 초기화 (테이블 생성) + db = TradeDB(db_path=str(db_path)) + + # 기본 env_config 삽입 + default_env = { + # 한투 API 설정 + "KIS_APP_KEY": os.environ.get("KIS_APP_KEY", ""), + "KIS_APP_SECRET": os.environ.get("KIS_APP_SECRET", ""), + "KIS_ACCOUNT_NO": os.environ.get("KIS_ACCOUNT_NO", ""), + "KIS_ACCOUNT_CODE": os.environ.get("KIS_ACCOUNT_CODE", "01"), + "KIS_ACCOUNT_NO_MOCK": os.environ.get("KIS_ACCOUNT_NO_MOCK", ""), + "KIS_ACCOUNT_CODE_MOCK": os.environ.get("KIS_ACCOUNT_CODE_MOCK", "01"), + "KIS_MOCK": os.environ.get("KIS_MOCK", "true"), + + # 단타 봇 설정 + "STOP_LOSS_PCT": "-0.03", + "TAKE_PROFIT_PCT": "0.05", + "MIN_DROP_RATE": "0.03", + "MIN_RECOVERY_RATIO_SHORT": "0.5", + "MAX_STOCKS": "3", + + # 늘림목 봇 설정 + "MAX_PER": "25", + "MAX_PEG": "1.5", + "MIN_GROWTH_PCT": "10", + "MAX_POSITION_PCT": "0.20", + "STOP_LOSS_PCT": "-0.30", + "TAKE_PROFIT_PCT": "0.50", + + # 공통 설정 + "USE_RISK_CHECK": "true", + "DAILY_STOP_LOSS_PCT": "-0.05", + "CONSECUTIVE_LOSS_LIMIT": "4", + "MAX_LOSS_PER_TRADE_KRW": "200000", + "USE_BAN_SYSTEM": "true", + "BAN_HOURS": "24", + "USE_STOCK_FILTER": "true", + "FORCE_MARKET_OPEN": "false", + } + + env_id = db.insert_env_snapshot(default_env) + if env_id: + print(f"✅ 기본 env_config 삽입 완료 (id: {env_id})") + else: + print("⚠️ env_config 삽입 실패") + + print(f"✅ DB 초기화 완료: {db_path}") + db.close() + +if __name__ == "__main__": + init_database() diff --git a/kis_long_ver1.py b/kis_long_ver1.py new file mode 100644 index 0000000..24e2b7f --- /dev/null +++ b/kis_long_ver1.py @@ -0,0 +1,1668 @@ +""" +KIS Long Trading Bot Ver1 - 늘림목 전략용 한투 API 트레이딩 시스템 +- 한국투자증권(KIS) Open API 사용 +- 장기 보유 + 분할 매수(늘림목) 전략 +- PER/PEG 기반 밸류에이션 +- kis_long_term_checker.py의 장기 전략을 참고하되, 늘림목 전략으로 수정 +""" + +import os +import json +import time +import random +import logging +import datetime +import hashlib +import hmac +import base64 +import warnings +import asyncio +from datetime import datetime as dt, timedelta +from pathlib import Path +from typing import List, Dict, Optional + +import pandas as pd +import requests + +# DB 모듈 임포트 +from database import TradeDB + +# 로깅 설정 (먼저 초기화) +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO +) +logger = logging.getLogger("KISLongBot") + +# Gemini API (AI 리포트용) +warnings.filterwarnings("ignore", message=".*google.generativeai.*") +try: + import google.generativeai as genai + GEMINI_AVAILABLE = True +except ImportError: + GEMINI_AVAILABLE = False + logger.warning("⚠️ google-generativeai 미설치 - AI 리포트 기능 사용 불가") + +# ML 예측 (선택적) +try: + from ml_predictor import MLPredictor + ML_AVAILABLE = True +except ImportError: + ML_AVAILABLE = False + logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가") + +# DB 초기화 +SCRIPT_DIR = Path(__file__).resolve().parent +db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) + +# DB에서 환경변수 로드 (초기 버전: Mattermost/Gemini 설정용) +def get_env_from_db(key, default=""): + """DB에서 환경변수 읽기 (초기화 단계)""" + env_data = db.get_latest_env() + if env_data and env_data.get("snapshot"): + return env_data["snapshot"].get(key, default) + return default + +# Mattermost 설정 +MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org") +MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json" +# 기본 채널(alias) + 롱/늘림목 봇 전용 채널(alias) +MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "stock") +MM_CHANNEL_LONG = get_env_from_db("KIS_LONG_MM_CHANNEL", MM_CHANNEL_DEFAULT) + +# Gemini API 설정 (기존 google.generativeai 그대로 유지; 추후 google.genai로 마이그레이션 가능) +GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip() +if GEMINI_API_KEY and 'GEMINI_AVAILABLE' in globals() and GEMINI_AVAILABLE: + try: + genai.configure(api_key=GEMINI_API_KEY) + gemini_model = genai.GenerativeModel('gemini-2.5-flash') + except Exception as e: + logger.warning(f"⚠️ Gemini 초기화 실패: {e}") + gemini_model = None +else: + gemini_model = None + +def get_env_float(key, default): + """환경변수를 float로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return float(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_int(key, default): + """환경변수를 int로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return int(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_bool(key, default=False): + """환경변수를 bool로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)).lower() + return value in ("true", "1", "yes") + +# ============================================================ +# 한투(KIS) API 클라이언트 (kis_long_term_checker.py 참고) +# ============================================================ +KIS_TOKEN_CACHE_PATH = SCRIPT_DIR / ".kis_token_cache.json" +KIS_TOKEN_EXPIRE_MARGIN_SEC = 300 + + +def _load_kis_token_cache(mock): + """캐시 파일에서 토큰 로드""" + if not KIS_TOKEN_CACHE_PATH.exists(): + return None + try: + with open(KIS_TOKEN_CACHE_PATH, "r", encoding="utf-8") as f: + cache = json.load(f) + if cache.get("mock") != mock: + return None + token = cache.get("access_token") + expired_str = cache.get("access_token_token_expired") or cache.get("expires_at") + if not token or not expired_str: + return None + try: + expired_dt = dt.strptime(expired_str.strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + if dt.now() >= expired_dt - timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC): + return None + return token + except Exception: + return None + + +def _save_kis_token_cache(access_token, access_token_token_expired, mock): + """토큰 캐시 저장""" + try: + with open(KIS_TOKEN_CACHE_PATH, "w", encoding="utf-8") as f: + json.dump({ + "access_token": access_token, + "access_token_token_expired": access_token_token_expired, + "mock": mock, + }, f, ensure_ascii=False, indent=0) + except Exception: + pass + + +class KISClientWithOrder: + """주문 기능이 추가된 KIS 클라이언트""" + 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자리로 직접 입력) + 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" + 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" + + # DB 값 그대로 사용 (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" + 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 확인.", + self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code) + ) + else: + logger.info("✅ 한투 계좌 CANO=%s, ACNT_PRDT_CD=%s (모의=%s)", self.acc_no, self.acc_code, use_mock) + self.mock = use_mock + # 모의(True)=모의 URL, 실전(False)=실전 URL (self.mock 사용, 인자 mock은 None일 수 있음) + if self.mock is True: + self.base_url = "https://openapivts.koreainvestment.com:29443" + else: + self.base_url = "https://openapi.koreainvestment.com:9443" + self.access_token = None + self._auth() + + def _auth(self): + """접근 토큰 발급""" + if not self.app_key or not self.app_secret: + raise ValueError("KIS_APP_KEY, KIS_APP_SECRET 설정 필요") + + cached = _load_kis_token_cache(self.mock) + if cached: + self.access_token = cached + logger.info("한투 토큰 캐시 사용 (%s)", "모의" if self.mock else "실전") + return + + url = f"{self.base_url}/oauth2/tokenP" + body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret} + try: + r = requests.post(url, json=body, timeout=10) + data = r.json() + if "access_token" in data: + self.access_token = data["access_token"] + expired = data.get("access_token_token_expired") or "" + _save_kis_token_cache(self.access_token, expired, self.mock) + logger.info("한투 토큰 발급 완료 (%s)", "모의" if self.mock else "실전") + else: + raise RuntimeError("한투 토큰 발급 실패") + except Exception as e: + logger.error("한투 인증 예외: %s", e) + raise + + def _get_hashkey(self, body): + """해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용""" + try: + url = f"{self.base_url}/uapi/hashkey" + headers = { + "content-type": "application/json", + "appkey": self.app_key, + "appsecret": self.app_secret, + } + r = requests.post(url, headers=headers, json=body, timeout=5) + if r.status_code == 200: + data = r.json() + if data.get("rt_cd") == "0": + return data.get("HASH") + return None + except Exception as e: + logger.debug(f"해시키 발급 실패: {e}") + return None + + def _headers(self, tr_id, hashkey=None): + """API 호출용 헤더""" + headers = { + "content-type": "application/json; charset=utf-8", + "authorization": f"Bearer {self.access_token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + } + if hashkey: + headers["hashkey"] = hashkey + return headers + + def _get(self, path, tr_id, params, max_retries=3): + """GET 요청. 429 시 지수 백오프 재시도""" + url = f"{self.base_url}{path}" + for attempt in range(max_retries): + try: + r = requests.get(url, headers=self._headers(tr_id), params=params, timeout=15) + if r.status_code == 429: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})") + time.sleep(wait_time) + continue + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + return r + elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도") + time.sleep(wait_time) + continue + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ GET 요청 실패 ({path}): {e}") + return r + + def inquire_price(self, stock_code): + """주식 현재가 시세 조회 [v1_국내주식-008]""" + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-price", + "FHKST01010100", + {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code}, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j.get("output") + + def get_orderbook(self, stock_code): + """호가 조회 [v1_국내주식-009]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", + "FHKST01010200", + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j.get("output") + except Exception as e: + logger.error(f"호가 조회 실패({stock_code}): {e}") + return None + + def get_account_evaluation(self): + """계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R.""" + if self.mock is True: + tr_id = "VTTC8494R" + else: + tr_id = "TTTC8494R" + try: + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl", + 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": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"계좌 평가 조회 실패: {e}") + return None + + def inquire_daily_itemchartprice(self, stock_code, start_ymd, end_ymd, period="D", adj="1"): + """국내주식 기간별 시세(일봉)""" + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", + "FHKST03010100", + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": period, + "FID_ORG_ADJ_PRC": adj, + }, + ) + if r.status_code != 200: + return [], [] + j = r.json() + if j.get("rt_cd") != "0": + return [], [] + out1 = j.get("output1") or {} + out2 = j.get("output2") or [] + return out1, out2 + + def finance_financial_ratio(self, stock_code, fid_div_cls_code="0"): + """국내주식 재무비율""" + try: + r = self._get( + "/uapi/domestic-stock/v1/finance/financial-ratio", + "FHKST66430300", + { + "FID_DIV_CLS_CODE": fid_div_cls_code, + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + out = j.get("output") + if isinstance(out, list) and out: + return out[0] + return out if isinstance(out, dict) else None + except Exception: + return None + + def finance_growth_ratio(self, stock_code, fid_div_cls_code="0"): + """국내주식 성장성비율""" + try: + r = self._get( + "/uapi/domestic-stock/v1/finance/growth-ratio", + "FHKST66430800", + { + "FID_INPUT_ISCD": stock_code, + "FID_DIV_CLS_CODE": fid_div_cls_code, + "FID_COND_MRKT_DIV_CODE": "J", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + out = j.get("output") + if isinstance(out, list) and out: + return out[0] + return out if isinstance(out, dict) else None + except Exception: + return None + + def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3): + """POST 요청. 해시키 사용 및 429 시 지수 백오프 재시도""" + url = f"{self.base_url}{path}" + hashkey = None + + if use_hashkey: + hashkey = self._get_hashkey(body) + if not hashkey: + logger.debug("해시키 발급 실패, 해시키 없이 진행") + + for attempt in range(max_retries): + try: + r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15) + if r.status_code == 429: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})") + time.sleep(wait_time) + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + return r + elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도") + time.sleep(wait_time) + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ POST 요청 실패 ({path}): {e}") + return r + + def buy_order(self, code, qty, price=0, order_type="01"): + """매수 주문 (모의=VTTC0802U, 실전=TTTC0802U) - 고급 주문 타입 지원""" + try: + if self.mock: + tr_id = "VTTC0802U" + else: + tr_id = "TTTC0802U" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": str(price) if price > 0 else "0", + } + r = self._post( + "/uapi/domestic-stock/v1/trading/order-cash", + tr_id, + body, + use_hashkey=True + ) + if r.status_code != 200: + logger.error(f"매수 주문 HTTP 에러: {r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매수 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error(f"매수 주문 실패: {j.get('msg1', '알 수 없음')}") + return False + except Exception as e: + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def buy_market_order(self, code, qty): + """시장가 매수 주문 (간편 메서드)""" + return self.buy_order(code, qty, price=0, order_type="01") + + def sell_order(self, code, qty, price=0, order_type="01"): + """매도 주문 (모의=VTTC0801U, 실전=TTTC0801U) - 고급 주문 타입 지원""" + try: + if self.mock: + tr_id = "VTTC0801U" + else: + tr_id = "TTTC0801U" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": str(price) if price > 0 else "0", + } + r = self._post( + "/uapi/domestic-stock/v1/trading/order-cash", + tr_id, + body, + use_hashkey=True + ) + if r.status_code != 200: + logger.error(f"매도 주문 HTTP 에러: {r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매도 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error(f"매도 주문 실패: {j.get('msg1', '알 수 없음')}") + return False + except Exception as e: + logger.error(f"매도 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문 (간편 메서드)""" + return self.sell_order(code, qty, price=0, order_type="01") + + def inquire_prices_batch(self, stock_codes: List[str]): + """다중 종목 현재가 일괄 조회 (최대 20개)""" + if not stock_codes or len(stock_codes) > 20: + logger.warning("종목 코드는 1~20개만 가능") + return {} + + result = {} + # 한투 API는 다중 종목 조회를 지원하지 않으므로 순차 조회 + for code in stock_codes: + try: + price_data = self.inquire_price(code) + if price_data: + result[code] = price_data + time.sleep(random.uniform(0.1, 0.3)) + except Exception as e: + logger.debug(f"종목 조회 실패({code}): {e}") + continue + + return result + + def get_investor_trend(self, stock_code, days=5): + """외국인/기관 매매 동향 조회""" + try: + end_dt = dt.now() + start_dt = end_dt - timedelta(days=days + 10) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + _, out2 = self.inquire_daily_itemchartprice(stock_code, start_ymd, end_ymd, "D", "1") + if not out2: + return None + + foreign_sum = 0 + org_sum = 0 + + for item in out2[:days]: + try: + foreign_raw = item.get("frgn_ntby_qty") or item.get("frgn_ntby_shnu") or "0" + foreign_net = int(float(str(foreign_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(foreign_raw).startswith("-"): + foreign_net = -foreign_net + + org_raw = item.get("orgn_ntby_qty") or "0" + org_net = int(float(str(org_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(org_raw).startswith("-"): + org_net = -org_net + + foreign_sum += foreign_net + org_sum += org_net + except: + continue + + return { + "foreign_net_buy": foreign_sum, + "org_net_buy": org_sum, + "total_net_buy": foreign_sum + org_sum, + } + except Exception as e: + logger.error(f"외국인/기관 동향 조회 실패({stock_code}): {e}") + return None + + +# ============================================================ +# 헬퍼 함수들 (kis_long_term_checker.py 참고) +# ============================================================ +def get_kis_daily_chart(client, stock_code, max_days=65, exchange=None): + """한투 일봉 조회 후 DataFrame 반환""" + end_dt = dt.now() + end_ymd = end_dt.strftime("%Y%m%d") + + if exchange: + return pd.DataFrame() # 해외 종목은 미구현 + + start_dt = end_dt - timedelta(days=max_days + 30) + start_ymd = start_dt.strftime("%Y%m%d") + _, out2 = client.inquire_daily_itemchartprice(stock_code, start_ymd, end_ymd, "D", "1") + if not out2: + return pd.DataFrame() + + rows = [] + for b in out2: + try: + date_str = (b.get("stck_bsop_date") or "").strip() + close_p = abs(float(str(b.get("stck_clpr") or "0").replace(",", ""))) + open_p = abs(float(str(b.get("stck_oprc") or "0").replace(",", ""))) + high_p = abs(float(str(b.get("stck_hgpr") or "0").replace(",", ""))) + low_p = abs(float(str(b.get("stck_lwpr") or "0").replace(",", ""))) + vol = int(float(str(b.get("acml_vol") or "0").replace(",", ""))) + except (TypeError, ValueError): + continue + if date_str and close_p > 0: + rows.append({"dt": date_str, "open": open_p, "high": high_p, "low": low_p, "close": close_p, "volume": vol}) + + if not rows: + return pd.DataFrame() + df = pd.DataFrame(rows) + df = df.sort_values("dt").reset_index(drop=True) + df["MA20"] = df["close"].rolling(window=20).mean() + df["MA60"] = df["close"].rolling(window=60).mean() + return df + + +def calculate_volatility(df, period=20): + """변동성 계산 (20일 표준편차 / 평균 * 100)""" + if df is None or df.empty or len(df) < period: + return None + try: + returns = df["close"].pct_change().dropna() + volatility = returns.tail(period).std() * 100 + return round(volatility, 2) if pd.notna(volatility) else None + except Exception: + return None + + +def get_kis_per_eps_peg(client, stock_code, current_price): + """한투 재무비율·성장성비율로 PER, EPS, 성장률, PEG 계산""" + try: + fin = client.finance_financial_ratio(stock_code, "0") + growth = client.finance_growth_ratio(stock_code, "0") + time.sleep(random.uniform(1, 2)) + + per, eps = None, None + if fin: + try: + eps_raw = fin.get("eps") or fin.get("EPS") + if eps_raw is not None: + eps = float(str(eps_raw).replace(",", "").strip()) + per_raw = fin.get("per") or fin.get("PER") + if per_raw is not None: + per = float(str(per_raw).replace(",", "").strip()) + except (TypeError, ValueError): + pass + if per is None and eps is not None and eps > 0 and current_price and current_price > 0: + per = current_price / eps + if per is not None and per <= 0: + per = None + + growth_pct = None + if growth: + for key in ("bsop_prfi_inrt", "ntin_inrt", "grs"): + val = growth.get(key) + if val is not None: + try: + growth_pct = float(str(val).replace(",", "").strip()) + if growth_pct != 0: + break + except (TypeError, ValueError): + continue + if fin and growth_pct is None: + growth_pct = fin.get("ntin_inrt") or fin.get("bsop_prfi_inrt") + if growth_pct is not None: + try: + growth_pct = float(str(growth_pct).replace(",", "").strip()) + except (TypeError, ValueError): + growth_pct = None + + peg = None + if per is not None and growth_pct is not None and growth_pct > 0: + peg = round(per / growth_pct, 2) + + return {"per": per, "eps": eps, "growth_pct": growth_pct, "peg": peg} + except Exception as e: + logger.error(f"PER/PEG 계산 실패 {stock_code}: {e}") + return {"per": None, "eps": None, "growth_pct": None, "peg": None} + + +# ============================================================ +# Mattermost 봇 클래스 +# ============================================================ +class MattermostBot: + """Mattermost 알림 봇""" + 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 MM_CONFIG_FILE.exists(): + 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 + + +# ============================================================ +# 늘림목 트레이딩 봇 +# ============================================================ +class LongTradingBot: + """늘림목 전략 트레이딩 봇 - 장기 보유 + 분할 매수""" + def __init__(self): + self.db = db + self.client = KISClientWithOrder() + + # Mattermost 초기화 + self.mm = MattermostBot() + # 롱/늘림목 봇 전용 채널(alias) 우선 사용, 없으면 기본 채널 사용 + self.mm_channel = MM_CHANNEL_LONG + + # 전략 파라미터 (DB에서 읽기) + self.max_per = get_env_float("MAX_PER", 25) + self.max_peg = get_env_float("MAX_PEG", 1.5) + self.min_growth_pct = get_env_float("MIN_GROWTH_PCT", 10) + + # 늘림목 파라미터 + dca_intervals_str = get_env_from_db("DCA_INTERVALS", "0,-0.05,-0.10,-0.15,-0.20") + self.dca_intervals = [float(x.strip()) for x in dca_intervals_str.split(",") if x.strip()] + dca_amounts_str = get_env_from_db("DCA_AMOUNTS", "100000,100000,100000,100000,100000") + self.dca_amounts = [int(x.strip()) for x in dca_amounts_str.split(",") if x.strip()] + self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.20) + + # 손절/익절 + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.30) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.50) + + # DB에서 활성 트레이드 로드 + self.holdings = {} + active_trades = self.db.get_active_trades() + for code, trade in active_trades.items(): + # 분할 매수 정보 복원 (간단화) + self.holdings[code] = { + "buy_prices": [trade.get("avg_buy_price", 0)], + "qtys": [trade.get("current_qty", 0)], + "total_qty": trade.get("current_qty", 0), + "avg_price": trade.get("avg_buy_price", 0), + "first_buy_date": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), + "name": trade.get("name", code), + } + + # 관심 종목 리스트 (JSON 파일에서 로드) + self.watchlist_path = SCRIPT_DIR / "long_term_watchlist.json" + self.watchlist = self._load_watchlist() + + # 초기 자산 조회 + self._update_assets() + + # 비동기 태스크 관리 + self._report_task = None + self._asset_task = None + + def _load_watchlist(self): + """관심 종목 리스트 로드""" + if not self.watchlist_path.exists(): + logger.warning(f"관심 종목 파일 없음: {self.watchlist_path}") + return [] + + try: + with open(self.watchlist_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("items", []) + except Exception as e: + logger.error(f"관심 종목 로드 실패: {e}") + return [] + + def _update_assets(self): + """자산 정보 업데이트""" + 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(): + 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 + except Exception as e: + logger.error(f"자산 정보 업데이트 실패: {e}") + + def _update_account_light(self, profit_val=0): + """ + 경량 계좌 갱신 (매수/매도 직후 즉시 호출!) + - API 부하를 줄이기 위해 예수금 + 보유 종목 평가액만 빠르게 계산 + - 총자산 = 예수금 + 보유 종목 평가액 (+ 손익 반영) + """ + 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 + except Exception as e: + logger.error(f"❌ 경량 갱신 실패: {e}") + return False + + def _update_cash_only(self): + """예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)""" + return self._update_account_light(profit_val=0) + + def _seconds_until_next_5min(self): + """다음 5분 정각까지 남은 초 계산""" + now = dt.now() + next_min = ((now.minute // 5) + 1) * 5 + if next_min >= 60: + next_time = now.replace(hour=now.hour + 1, minute=0, second=0, microsecond=0) + else: + next_time = now.replace(minute=next_min, second=0, microsecond=0) + return (next_time - now).total_seconds() + + async def _report_scheduler(self): + """리포트 전송 스케줄러 (비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 13:00 - 오전 리포트 + AI 리포트 + if now.hour == 13 and now.minute == 0 and not self.morning_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_morning_report) + await loop.run_in_executor(None, self.send_ai_report) + + # 15:15 - 장마감 전 리포트 + elif now.hour == 15 and now.minute == 15 and not self.closing_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_closing_report) + + # 15:35 - 최종 리포트 + elif now.hour == 15 and now.minute == 35 and not self.final_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_final_report) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [리포트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _asset_update_scheduler(self): + """자산 정보 업데이트 스케줄러 (30분마다, 비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 30분마다 자산 업데이트 + if now.minute % 30 == 0: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._update_assets) + await asyncio.sleep(60) # 업데이트 후 1분 대기 (중복 방지) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [자산 업데이트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + def send_mm(self, msg): + """Mattermost 알림 전송""" + try: + self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + + def check_market_status(self): + """장 운영 시간 체크""" + # FORCE_MARKET_OPEN 플래그 확인 (테스트용) + force_open = get_env_bool("FORCE_MARKET_OPEN", False) + if force_open: + logger.debug("🔓 FORCE_MARKET_OPEN=true - 장 상태 무시하고 계속 진행") + return True + + # 정상 장 운영 시간 체크 + now = dt.now() + if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)): + return False + if now.weekday() >= 5: # 주말 + return False + return True + + def send_morning_report(self): + """오전 장 뜸할 때 리포트 (13:00)""" + if self.morning_report_sent: + return + + self._update_assets() + 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"""📊 **[오전 장 현황 - 13:00]** +- 당일 시작: {self.start_day_asset:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 보유 종목: {len(self.holdings)}개""" + + self.send_mm(msg) + self.morning_report_sent = True + logger.info("📊 오전 리포트 전송 완료") + + def send_closing_report(self): + """장마감 전 리포트 (15:15)""" + if self.closing_report_sent: + return + + self._update_assets() + 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"""📈 **[장마감 전 현황 - 15:15]** +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 현재 자산: {self.current_total_asset:,.0f}원 +- 보유 종목: {len(self.holdings)}개 +- 예수금: {self.current_cash:,.0f}원""" + + self.send_mm(msg) + self.closing_report_sent = True + logger.info("📈 장마감 전 리포트 전송 완료") + + def send_final_report(self): + """장마감 후 최종 리포트 (15:35)""" + if self.final_report_sent: + return + + self._update_assets() + + # 당일 손익 + 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_trades = self.db.get_trades_by_date(self.today_date) + + msg = f"""🏁 **[장마감 최종 보고 - 15:35]** +━━━━━━━━━━━━━━━━━━━━ +📅 **당일 손익** +- 시작: {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) + self.final_report_sent = True + logger.info("🏁 장마감 최종 리포트 전송 완료") + + def send_ai_report(self): + """AI 분석 리포트 (13:00)""" + if self.ai_report_sent or not gemini_model: + return + + try: + # 최근 거래 내역 조회 + recent_trades = [] + try: + conn = self.db.conn + cursor = conn.execute(""" + SELECT code, name, buy_price, sell_price, qty, profit_rate, + realized_pnl, strategy, sell_reason, buy_date, sell_date, hold_minutes + FROM trade_history + WHERE strategy LIKE 'LONG%' + ORDER BY id DESC + LIMIT 10 + """) + for row in cursor.fetchall(): + recent_trades.append({ + 'code': row[0], + 'name': row[1], + 'buy_price': row[2], + 'sell_price': row[3], + 'qty': row[4], + 'profit_rate': row[5], + 'realized_pnl': row[6], + 'strategy': row[7], + 'sell_reason': row[8], + 'buy_date': row[9], + 'sell_date': row[10], + 'hold_minutes': row[11] or 0 + }) + except Exception as e: + logger.error(f"거래 내역 조회 실패: {e}") + return + + if not recent_trades: + return + + # 통계 계산 + total = len(recent_trades) + wins = sum(1 for t in recent_trades if t['profit_rate'] > 0) + losses = total - wins + win_rate = wins / total * 100 if total > 0 else 0 + avg_profit = sum(t['profit_rate'] for t in recent_trades) / total + total_pnl = sum(t['realized_pnl'] for t in recent_trades) + avg_hold_days = sum(t['hold_minutes'] for t in recent_trades) / total / 1440 # 분을 일로 변환 + + # AI 분석 + trades_text = "" + for i, t in enumerate(recent_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}원) +- 보유: {avg_hold_days:.1f}일 +- 사유: {t['sell_reason']} +""" + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +다음은 최근 {total}건의 장기/늘림목 거래 내역입니다: + +{trades_text} + +📊 통계: +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:,.0f}원 +- 평균 보유: {avg_hold_days:.1f}일 + +**당신의 임무:** +1. 문제점 3가지 진단 (구체적으로) +2. DB 설정 수정 권장사항 (변수명=값 형식) +3. 예상 효과 + +**출력 형식:** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] +3. [구체적 문제 3] + +## 💡 권장 수정사항 +``` +MAX_PER=XX +MAX_PEG=X.XX +DCA_INTERVALS=X,-X.XX,-X.XX +... +``` + +## 📈 예상 효과 +- [효과 1] +- [효과 2] + +**간결하고 명확하게 답변하세요.** +""" + + response = gemini_model.generate_content(prompt) + analysis = response.text + + summary = f"""📊 최근 {total}건 거래 통계 +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:,.0f}원 +- 평균 보유: {avg_hold_days:.1f}일""" + + message = f"""🤖 **[13시 AI 자동 분석]** + +{summary} + +{analysis} + +--- +💬 늘림목 전략 최적화를 위한 AI 분석입니다. +""" + + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료") + + except Exception as e: + logger.error(f"AI 리포트 생성 실패: {e}") + + def analyze_stock_value(self, code, name): + """종목 밸류에이션 분석 - 고도화된 분석""" + try: + # 현재가 조회 + price_data = self.client.inquire_price(code) + if not price_data: + return None + + current_price = abs(float(price_data.get("stck_prpr", 0))) + if current_price == 0: + return None + + # PER/PEG 조회 + fund_data = get_kis_per_eps_peg(self.client, code, current_price) + per = fund_data.get("per") + peg = fund_data.get("peg") + growth_pct = fund_data.get("growth_pct") + + # 차트 분석 + df = get_kis_daily_chart(self.client, code, max_days=65) + if df.empty: + return None + + # RSI 계산 + delta = df["close"].diff(1) + gain = delta.where(delta > 0, 0).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss.replace(0, float("nan")) + rsi = 100 - (100 / (1 + rs)) + current_rsi = float(rsi.iloc[-1]) if len(rsi) > 0 else None + + # [필터 1] RSI 과열 체크 + rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) + if current_rsi and current_rsi >= rsi_threshold: + logger.info(f"🔍 [Pass-RSI] {name} {code}: RSI 과열 ({current_rsi:.1f} >= {rsi_threshold})") + return None + + # 변동성 + volatility = calculate_volatility(df) + + # 이동평균 + ma20 = float(df["MA20"].iloc[-1]) if "MA20" in df.columns and len(df) >= 20 else None + ma60 = float(df["MA60"].iloc[-1]) if "MA60" in df.columns and len(df) >= 60 else None + + # [필터 2] MA20 체크 + if ma20 and current_price < ma20: + logger.info(f"🔍 [Pass-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f})") + return None + + # 외국인/기관 동향 분석 + investor_trend = self.client.get_investor_trend(code, days=5) + investor_score = 0 + investor_signal = "중립" + if investor_trend: + total_net = investor_trend.get("total_net_buy", 0) + if total_net > 20000: + investor_score = 25 + investor_signal = "강한 매수세" + elif total_net > 5000: + investor_score = 15 + investor_signal = "매수세" + elif total_net < -20000: + investor_score = -25 + investor_signal = "강한 매도세" + elif total_net < -5000: + investor_score = -15 + investor_signal = "매도세" + + # 종합 점수 계산 + score = 50 # 기본 점수 + + # PER 점수 + if per is not None: + if per < 10: + score += 30 + elif per < 15: + score += 20 + elif per < 20: + score += 10 + elif per > 30: + score -= 20 + + # PEG 점수 + if peg is not None: + if peg < 1.0: + score += 30 + elif peg < 1.5: + score += 15 + elif peg > 2.0: + score -= 20 + + # RSI 점수 + if current_rsi is not None: + if current_rsi < 30: + score += 20 # 과매도 구간 + elif current_rsi < 40: + score += 10 + elif current_rsi > 70: + score -= 20 # 과매수 구간 + + # 이동평균 점수 + if ma20 is not None and ma60 is not None: + if ma20 > ma60: + score += 10 # 정배열 + ma_gap_pct = ((ma20 - ma60) / ma60) * 100 + if ma_gap_pct > 5: + score += 5 # 강한 상승세 + + # 외국인/기관 점수 추가 + score += investor_score + + return { + "code": code, + "name": name, + "current_price": current_price, + "per": per, + "peg": peg, + "growth_pct": growth_pct, + "rsi": current_rsi, + "volatility": volatility, + "ma20": ma20, + "ma60": ma60, + "investor_trend": investor_trend, + "investor_signal": investor_signal, + "score": score, + "is_buyable": ( + (per is None or per <= self.max_per) and + (peg is None or peg <= self.max_peg) and + (growth_pct is None or growth_pct >= self.min_growth_pct) and + score >= 60 and + investor_score >= -10 # 강한 매도세는 제외 + ), + } + + # 필터링 로그 출력 + if not analysis["is_buyable"]: + reasons = [] + if per and per > self.max_per: + reasons.append(f"PER {per:.1f} > {self.max_per}") + if peg and peg > self.max_peg: + reasons.append(f"PEG {peg:.2f} > {self.max_peg}") + if growth_pct and growth_pct < self.min_growth_pct: + reasons.append(f"성장률 {growth_pct:.1f}% < {self.min_growth_pct}%") + if score < 60: + 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 { + } + except Exception as e: + logger.error(f"종목 분석 실패({code}): {e}") + return None + + def check_dca_opportunity(self, code, holding): + """늘림목(분할 매수) 기회 체크""" + if code not in self.holdings: + return None + + try: + price_data = self.client.inquire_price(code) + if not price_data: + return None + + current_price = abs(float(price_data.get("stck_prpr", 0))) + avg_price = holding["avg_price"] + + # 평단가 대비 하락률 계산 + drop_pct = (current_price - avg_price) / avg_price + + # 분할 매수 구간 확인 + 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 # 이미 매수한 구간 + + # 분할 매수 실행 + amount = self.dca_amounts[i] if i < len(self.dca_amounts) else self.dca_amounts[-1] + qty = int(amount / current_price) + if qty > 0: + return { + "code": code, + "name": holding.get("name", code), + "price": current_price, + "qty": qty, + "drop_pct": drop_pct, + "interval": interval, + } + + return None + except Exception as e: + logger.error(f"늘림목 체크 실패({code}): {e}") + return None + + def check_sell_signals(self): + """매도 신호 체크 (손절/익절)""" + if not self.holdings: + return [] + + sell_signals = [] + for code, holding in list(self.holdings.items()): + try: + price_data = self.client.inquire_price(code) + if not price_data: + continue + + current_price = abs(float(price_data.get("stck_prpr", 0))) + avg_price = holding["avg_price"] + profit_pct = (current_price - avg_price) / avg_price + + # 손절 또는 익절 체크 + if profit_pct <= self.stop_loss_pct: + sell_signals.append({ + "code": code, + "name": holding.get("name", code), + "reason": "손절", + "profit_pct": profit_pct, + "qty": holding["total_qty"], + }) + elif profit_pct >= self.take_profit_pct: + sell_signals.append({ + "code": code, + "name": holding.get("name", code), + "reason": "익절", + "profit_pct": profit_pct, + "qty": holding["total_qty"], + }) + + time.sleep(random.uniform(0.3, 0.7)) + except Exception as e: + logger.error(f"매도 신호 체크 실패({code}): {e}") + continue + + return sell_signals + + def execute_buy(self, signal, is_dca=False): + """매수 실행""" + code = signal["code"] + name = signal["name"] + price = signal["price"] + qty = signal["qty"] + + # 🔥 매수 직전 예수금 실시간 확인 (30분마다 업데이트된 값이 부정확할 수 있음) + if not self._update_account_light(profit_val=0): + logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵") + return False + + # 예수금 부족 체크 (수수료 포함 여유분 5% 고려) + required_amount = price * qty * 1.05 # 수수료 포함 + if self.current_cash < required_amount: + logger.warning( + f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / " + f"보유 {self.current_cash:,.0f}원 -> 매수 스킵" + ) + return False + + # 매수 주문 + success = self.client.buy_market_order(code, qty) + if success: + # 매수 후 예수금 + 총자산 즉시 업데이트 (다음 매수 시 정확한 예수금 확인) + self._update_account_light(profit_val=0) + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + + if code not in self.holdings: + self.holdings[code] = { + "buy_prices": [], + "qtys": [], + "total_qty": 0, + "avg_price": 0, + "first_buy_date": buy_time, + "name": name, + } + + holding = self.holdings[code] + holding["buy_prices"].append(price) + holding["qtys"].append(qty) + holding["total_qty"] += qty + + # 평단가 재계산 + total_cost = sum(p * q for p, q in zip(holding["buy_prices"], holding["qtys"])) + holding["avg_price"] = total_cost / holding["total_qty"] + + # DB에 저장/업데이트 + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "LONG_DCA" if is_dca else "LONG_INITIAL", + "avg_buy_price": holding["avg_price"], + "current_price": price, + "target_qty": holding["total_qty"], + "current_qty": holding["total_qty"], + "status": "HOLDING", + "buy_date": holding["first_buy_date"], + }) + + action = "늘림목 매수" if is_dca else "초기 매수" + logger.info(f"💰 [{action}] {name} ({code}): {price:,.0f}원 × {qty}주 (평단: {holding['avg_price']:,.0f}원)") + return True + + return False + + def execute_sell(self, signal): + """매도 실행""" + code = signal["code"] + name = signal["name"] + qty = signal["qty"] + + if code not in self.holdings: + logger.warning(f"⚠️ [{name}] 보유 종목 아님") + return False + + # 매도 주문 + success = self.client.sell_market_order(code, qty) + if success: + # 현재가 조회 + price_data = self.client.inquire_price(code) + sell_price = abs(float(price_data.get("stck_prpr", 0))) if price_data else signal.get("price", 0) + + holding = self.holdings[code] + profit_pct = signal["profit_pct"] + + # DB에서 매도 처리 + self.db.close_trade( + code=code, + sell_price=sell_price, + sell_reason=signal['reason'], + ) + + # 손익 계산 (매도 후 총자산 반영용) + profit_val = (sell_price - holding["avg_price"]) * qty # 손익 금액 + + del self.holdings[code] + + # 🔥 매도 후 예수금 + 총자산 즉시 업데이트 (손익 반영) + self._update_account_light(profit_val=profit_val) + + logger.info( + f"💸 [매도 체결] {name} ({code}): {qty}주 " + f"({signal['reason']}, {profit_pct*100:+.2f}%, 평단: {holding['avg_price']:,.0f}원)" + ) + return True + + return False + + def run(self): + """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """비동기 메인 루프 - 백그라운드 태스크 시작 후 동기 매매 루프 실행""" + logger.info("🚀 늘림목 트레이딩 봇 시작 (비동기 백그라운드 작업 활성화)") + + # 백그라운드 태스크 시작 + self._report_task = asyncio.create_task(self._report_scheduler()) + self._asset_task = asyncio.create_task(self._asset_update_scheduler()) + logger.info("✅ 백그라운드 태스크 시작 완료 (리포트, 자산 업데이트)") + + # 동기 매매 루프는 별도 스레드에서 실행 (메인 이벤트 루프 블로킹 방지) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._sync_trading_loop) + + def _sync_trading_loop(self): + """동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리""" + logger.info("📈 매매 루프 시작 (동기 모드)") + + while True: + try: + now = dt.now() + current_date = now.strftime("%Y-%m-%d") + + # 날짜 변경 시 리포트 플래그 리셋 + if current_date != self.today_date: + self.today_date = current_date + 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 + logger.info(f"📅 날짜 변경: {current_date}") + + # 리포트 전송은 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.hour == 13 and now.minute == 0: + # self.send_morning_report() + # self.send_ai_report() + # elif now.hour == 15 and now.minute == 15: + # self.send_closing_report() + # elif now.hour == 15 and now.minute == 35: + # self.send_final_report() + + # 장 상태 체크 + if not self.check_market_status(): + logger.info("⏸ 장 시간 아님 - 대기 중...") + time.sleep(60) + continue + + # 자산 정보 업데이트는 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.minute % 30 == 0: # 30분마다 + # self._update_assets() + + # 매도 신호 체크 (우선) - 메인 루프에서 처리 + sell_signals = self.check_sell_signals() + for signal in sell_signals: + self.execute_sell(signal) + time.sleep(random.uniform(1, 2)) + + # 늘림목 기회 체크 + for code, holding in list(self.holdings.items()): + dca_signal = self.check_dca_opportunity(code, holding) + if dca_signal: + self.execute_buy(dca_signal, is_dca=True) + time.sleep(random.uniform(1, 2)) + + # 신규 매수 기회 스캔 (관심 종목 리스트 기준) + active_count = len(self.holdings) + if len(self.watchlist) > 0: + logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(self.watchlist)}개 | 보유:{active_count}/{self.max_stocks}") + + for item in self.watchlist: + code = item.get("code", "").strip() + name = item.get("name", code) + + if not code or code in self.holdings: + continue + + # 밸류에이션 분석 + analysis = self.analyze_stock_value(code, name) + if not analysis or not analysis["is_buyable"]: + continue + + # 초기 매수 실행 + signal = { + "code": code, + "name": name, + "price": analysis["current_price"], + "qty": int(100000 / analysis["current_price"]), # 10만원 분할 + } + self.execute_buy(signal, is_dca=False) + time.sleep(random.uniform(2, 4)) + break # 한 번에 하나씩만 + + # 대기 + time.sleep(random.uniform(10, 15)) + + except KeyboardInterrupt: + logger.info("⏹ 봇 종료") + # 백그라운드 태스크 취소 + if self._report_task: + self._report_task.cancel() + if self._asset_task: + self._asset_task.cancel() + break + except Exception as e: + logger.error(f"❌ 루프 에러: {e}") + import traceback + logger.error(traceback.format_exc()) + time.sleep(10) + + +if __name__ == "__main__": + bot = LongTradingBot() + bot.run() diff --git a/kis_short_ver1.py b/kis_short_ver1.py new file mode 100644 index 0000000..e0bab0a --- /dev/null +++ b/kis_short_ver1.py @@ -0,0 +1,3296 @@ +""" +KIS Short Trading Bot Ver1 - 단타용 한투 API 트레이딩 시스템 +- 한국투자증권(KIS) Open API 사용 +- 개미털기(눌림목) 전략 기반 단타 매매 +- 빠른 손절/익절 로직 +- kiwoom_trader_ver2.py의 단타 전략을 한투 API로 변환 +""" + +import os +import re +import json +import time +import random +import logging +import datetime +import hashlib +import hmac +import base64 +import warnings +import asyncio +from datetime import datetime as dt +from pathlib import Path +from typing import List, Dict, Optional + +import pandas as pd +import requests + +from database import TradeDB + +# 로깅 설정 +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO, +) +logger = logging.getLogger("KISShortBot") + +# 로그 색상 (ANSI) - 탈락/통과 구분 +LOG_RED = "\033[91m" # 탈락 +LOG_YELLOW = "\033[93m" # 탈락 (Pass-조건) +LOG_GREEN = "\033[92m" # 통과 +LOG_CYAN = "\033[96m" # 강조 +LOG_RESET = "\033[0m" + +# DB 초기화 +SCRIPT_DIR = Path(__file__).resolve().parent +db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) + +# DB에서 환경변수 로드 +def get_env_from_db(key, default=""): + """DB에서 환경변수 읽기""" + env_data = db.get_latest_env() + if env_data and env_data.get("snapshot"): + return env_data["snapshot"].get(key, default) + return default + +def get_env_float(key, default): + """환경변수를 float로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return float(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_int(key, default): + """환경변수를 int로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return int(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_bool(key, default=False): + """환경변수를 bool로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)).lower() + return value in ("true", "1", "yes") + +# Mattermost 설정 +MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org") +MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json" +# 기본 채널(alias) + 단타 봇 전용 채널(alias) +MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "stock") +MM_CHANNEL_SHORT = get_env_from_db("KIS_SHORT_MM_CHANNEL", MM_CHANNEL_DEFAULT) + +# Gemini API (AI 리포트용) - google.genai 신규 SDK (Client 사용, configure 없음) +try: + import google.genai as genai + GEMINI_AVAILABLE = True +except ImportError: + GEMINI_AVAILABLE = False + logger.warning("⚠️ google-genai 미설치 - AI 리포트 기능 사용 불가") + +GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip() +GEMINI_MODEL_ID = "gemini-2.5-flash" # 또는 gemini-2.5-flash (모델명 확인) +gemini_client = None +if GEMINI_API_KEY and GEMINI_AVAILABLE: + try: + gemini_client = genai.Client(api_key=GEMINI_API_KEY) + except Exception as e: + logger.warning(f"⚠️ Gemini 초기화 실패: {e}") + gemini_client = None +else: + gemini_client = None + +# ML 예측 (선택적) +try: + from ml_predictor import MLPredictor + ML_AVAILABLE = True +except ImportError: + ML_AVAILABLE = False + logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가") + +# RiskManager (변동성 기반 리스크 관리) +try: + from risk_manager import RiskManager + RISK_MANAGER_AVAILABLE = True +except ImportError: + RISK_MANAGER_AVAILABLE = False + logger.warning("⚠️ risk_manager 미설치 - 변동성 역가중 매수 금액 계산 불가") + +# ============================================================ +# 한투(KIS) API 클라이언트 (kis_long_term_checker.py 참고) +# ============================================================ +# 모의계좌용 토큰 캐시 경로 +KIS_TOKEN_CACHE_PATH_MOCK = SCRIPT_DIR / ".kis_token_cache_mock.json" +# 실계좌용 토큰 캐시 경로 +KIS_TOKEN_CACHE_PATH_REAL = SCRIPT_DIR / ".kis_token_cache_real.json" + + + +# 한투 접근 토큰 유효기간 24시간. 자주 발급하면 영구 제명될 수 있으므로 캐시 철저 재사용. +# 만료 1분 전에만 재발급 (불필요한 발급 최소화) +KIS_TOKEN_EXPIRE_MARGIN_SEC = 60 + + +def _parse_kis_token_expired(expired_str): + """한투 API 만료시간 문자열 파싱. 'YYYY-MM-DD HH:MM:SS' 또는 'YYYY-MM-DDTHH:MM:SS' 등 지원.""" + if not expired_str or not isinstance(expired_str, str): + return None + s = expired_str.strip().replace("T", " ")[:19] + if len(s) < 19: + return None + try: + return dt.strptime(s, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def _load_kis_token_cache(mock): + """캐시 파일에서 토큰 로드. 만료 1분 전까지 유효하면 재사용 (24h 토큰 자주 발급 시 영구 제명 주의).""" + if mock: + path = KIS_TOKEN_CACHE_PATH_MOCK + else: + path = KIS_TOKEN_CACHE_PATH_REAL + if not path.exists(): + logger.info("한투 토큰 캐시 없음 → API 발급 예정 (캐시 경로: %s)", path) + return None + try: + logger.info("패스 %s", path) + with open(path, "r", encoding="utf-8") as f: + cache = json.load(f) + if cache.get("mock") != mock: + logger.info("한투 토큰 캐시 모의/실전 불일치 → API 발급 예정") + return None + token = cache.get("access_token") + expired_str = cache.get("access_token_token_expired") or cache.get("expires_at") + if not token or not expired_str: + logger.info("한투 토큰 캐시 내용 불완전 → API 발급 예정") + return None + expired_dt = _parse_kis_token_expired(expired_str) + if expired_dt is None: + logger.info("한투 토큰 캐시 만료시간 파싱 실패(%s) → API 발급 예정", expired_str[:30]) + return None + if dt.now() >= expired_dt - datetime.timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC): + logger.info("한투 토큰 캐시 만료 임박(%s) → API 발급 예정", expired_str[:19]) + return None + return token + except Exception as e: + logger.warning("한투 토큰 캐시 로드 실패(%s): %s", path, e) + return None + + +def _save_kis_token_cache(access_token, access_token_token_expired, mock): + """발급받은 토큰을 캐시 파일에 저장.""" + try: + if mock: + path = KIS_TOKEN_CACHE_PATH_MOCK + else: + path = KIS_TOKEN_CACHE_PATH_REAL + with open(path, "w", encoding="utf-8") as f: + json.dump({ + "access_token": access_token, + "access_token_token_expired": access_token_token_expired, + "mock": mock, + }, f, ensure_ascii=False, indent=2) + logger.info("한투 토큰 캐시 저장 완료: %s", path) + except Exception as e: + logger.warning("한투 토큰 캐시 저장 실패: %s", e) + + +class KISClient: + """한국투자증권 Open API 클라이언트""" + def __init__(self, mock=None): + + # 모의 여부 결정 + if mock is not None: + use_mock = mock + else: + use_mock = get_env_bool("KIS_MOCK", True) + + # 모의투자는 MOCK 전용 키만 사용(실전 키로 폴백 안 함 → 토큰/캐시가 실전이랑 섞이지 않도록) + if use_mock: + self.app_key = get_env_from_db("KIS_APP_KEY_MOCK", "").strip() + self.app_secret = get_env_from_db("KIS_APP_SECRET_MOCK", "").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: + 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", "").strip() + raw_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "").strip() + if not raw_code: + raw_code = "01" + 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 + 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 확인.", + self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code) + ) + else: + logger.info("✅ 한투 계좌 CANO=%s, ACNT_PRDT_CD=%s (모의=%s)", self.acc_no, self.acc_code, use_mock) + + self.mock = use_mock + + if self.mock is True: + self.base_url = "https://openapivts.koreainvestment.com:29443" + else: + self.base_url = "https://openapi.koreainvestment.com:9443" + + self.access_token = None + logger.info("한투 API 연결: 모의=%s → %s", self.mock, self.base_url) + self._auth() + + + def _auth(self): + """접근 토큰 발급""" + if not self.app_key or not self.app_secret: + if self.mock: + key_hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" + else: + key_hint = "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)" + logger.error("한투 API 키가 없습니다. DB env_config에 설정 필요: %s", key_hint) + raise ValueError("KIS 앱키/시크릿 설정 필요 (모의=%s)" % self.mock) + + # ✅ path를 먼저 정의 (발급 성공/실패 양쪽에서 사용) + path = KIS_TOKEN_CACHE_PATH_MOCK if self.mock else KIS_TOKEN_CACHE_PATH_REAL + mode_str = "모의" if self.mock else "실전" + + cached = _load_kis_token_cache(self.mock) + if cached: + self.access_token = cached + token_head = (cached[:8] + "…") if cached and len(cached) > 8 else "(없음)" + logger.info("한투 토큰 캐시 사용 (%s) | 파일=%s | 토큰앞8=%s", mode_str, path, token_head) + return + + # 캐시 없음/만료 → API로 새 토큰 발급 (캐시 파일 없어도 자동 발급) + appkey_tail = (self.app_key[-4:] if len(self.app_key) >= 4 else self.app_key) or "????" + logger.info( + "한투 토큰 발급 요청 (%s) | 앱키 끝4자리=%s | 저장할 캐시=%s", + mode_str, appkey_tail, path, + ) + url = f"{self.base_url}/oauth2/tokenP" + body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret} + try: + r = requests.post(url, json=body, timeout=10) + data = r.json() + if "access_token" in data: + self.access_token = data["access_token"] + expired = data.get("access_token_token_expired") or "" + _save_kis_token_cache(self.access_token, expired, self.mock) + token_head = (self.access_token[:8] + "…") if self.access_token and len(self.access_token) > 8 else "(없음)" + logger.info( + "한투 토큰 발급 완료 (%s) | 캐시=%s | 앱키끝4=%s | 토큰앞8=%s", + mode_str, path, appkey_tail, token_head, + ) + else: + logger.error("한투 토큰 발급 실패: %s", data) + if isinstance(data, dict) and data.get("error_code") == "EGW00133": + logger.warning("한투 1분당 1회 제한. 1분 후 재시도하거나 캐시 사용: %s", path) + raise RuntimeError("한투 토큰 발급 실패") + except Exception as e: + logger.error("한투 인증 예외: %s", e) + raise + + def _get_hashkey(self, body): + """해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용""" + try: + url = f"{self.base_url}/uapi/hashkey" + headers = { + "content-type": "application/json", + "appkey": self.app_key, + "appsecret": self.app_secret, + } + r = requests.post(url, headers=headers, json=body, timeout=5) + if r.status_code == 200: + data = r.json() + if data.get("rt_cd") == "0": + return data.get("HASH") + return None + except Exception as e: + logger.debug(f"해시키 발급 실패: {e}") + return None + + def _headers(self, tr_id, hashkey=None): + """API 호출용 헤더 생성""" + headers = { + "content-type": "application/json; charset=utf-8", + "authorization": f"Bearer {self.access_token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + } + if hashkey: + headers["hashkey"] = hashkey + return headers + + def _get(self, path, tr_id, params, max_retries=3, tr_cont=None): + """ + GET 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 시간 증가 재시도 + - 한투 API 제한: 초당 20개 (실제로는 더 엄격, 모의투자는 초당 2~3회 권장) + - EGW00201 감지 시: 5초 + (attempt * 1초) 대기 후 재시도 + - 기본 호출 간격: 0.5초 이상 권장 + """ + url = f"{self.base_url}{path}" + headers = self._headers(tr_id) + if tr_cont: + headers["tr_cont"] = tr_cont # 연속 조회 시 다음 페이지 요청 (한투: Response Header tr_cont=M 이면 Request Header tr_cont=N) + logger.debug(f"[API호출] GET {path} TR_ID={tr_id} params={params} tr_cont={tr_cont}") + + time.sleep(0.5) + + for attempt in range(max_retries): + try: + r = requests.get(url, headers=headers, params=params, timeout=15) + + # HTTP 429 (Too Many Requests) + if r.status_code == 429: + wait_time = 1 + (attempt * 1) # 5초, 6초, 7초... + logger.warning( + f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) path={path}" + ) + time.sleep(wait_time) + continue + + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + logger.debug(f"[API성공] GET {path} TR_ID={tr_id} status=200 rt_cd=0") + return r + # EGW00201: 초당 거래건수 초과 + elif j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = 1 + (attempt * 1) # 5초, 6초, 7초... (키움 봇 방식) + logger.warning( + f"⏳ API 과부하 (EGW00201) GET {path} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + time.sleep(wait_time) + continue + # HTTP 200이 아니거나 rt_cd != "0"인 경우 + try: + body_preview = (r.text or "")[:500] + except Exception: + body_preview = "" + logger.warning( + f"[API실패] GET {path} TR_ID={tr_id} status={r.status_code} " + f"params={params} body={body_preview}" + ) + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ GET 요청 실패 ({path}): {e}") + return r + + def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3): + """ + POST 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 시간 증가 재시도 + - 한투 API 제한: 초당 20개 (실제로는 더 엄격) + - EGW00201 감지 시: 5초 + (attempt * 1초) 대기 후 재시도 + """ + url = f"{self.base_url}{path}" + hashkey = None + + # API 호출 정보 디버그 로그 (민감 정보는 일부만) + body_preview = str(body)[:200] if body else "{}" + logger.debug(f"[API호출] POST {path} TR_ID={tr_id} body={body_preview}...") + + # 기본 안전 대기 (서버 부하 완화) + time.sleep(0.5) + + # 해시키 발급 (선택적이지만 보안 강화) + if use_hashkey: + hashkey = self._get_hashkey(body) + if not hashkey: + logger.debug("해시키 발급 실패, 해시키 없이 진행") + + for attempt in range(max_retries): + try: + r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15) + + # HTTP 429 (Too Many Requests) + if r.status_code == 429: + wait_time = 5 + (attempt * 1) # 5초, 6초, 7초... + logger.warning( + f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) path={path}" + ) + time.sleep(wait_time) + # 해시키 재발급 + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + logger.debug(f"[API성공] POST {path} TR_ID={tr_id} status=200 rt_cd=0") + return r + # EGW00201: 초당 거래건수 초과 + elif j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = 5 + (attempt * 1) # 5초, 6초, 7초... (키움 봇 방식) + logger.warning( + f"⏳ API 과부하 (EGW00201) POST {path} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + time.sleep(wait_time) + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + # HTTP 200이 아니거나 rt_cd != "0"인 경우 + try: + body_preview = (r.text or "")[:500] + except Exception: + body_preview = "" + logger.warning( + f"[API실패] POST {path} TR_ID={tr_id} status={r.status_code} " + f"body={body_preview}" + ) + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ POST 요청 실패 ({path}): {e}") + return r + + def inquire_price(self, stock_code): + """ + 주식 현재가 시세 조회 [v1_국내주식-008] (단건). + output: stck_prpr(현재가) ✅, stck_oprc(시가), stck_hgpr(고가), stck_lwpr(저가) 등 당일 OHLC 포함. + 실패 시 오류코드(rt_cd, msg_cd, msg1) 로깅. + """ + path = "/uapi/domestic-stock/v1/quotations/inquire-price" + tr_id = "FHKST01010100" + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code} + logger.debug(f"[현재가API] 호출 code={stock_code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + try: + body_preview = (r.text or "")[:300] + except Exception: + body_preview = "" + logger.warning( + f"[현재가API] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} " + f"status={r.status_code} body={body_preview}" + ) + return None + try: + j = r.json() + except Exception as e: + logger.warning( + f"[현재가API] JSON 파싱 실패 code={stock_code} path={path} TR_ID={tr_id} exception={e}" + ) + return None + if j.get("rt_cd") != "0": + logger.warning( + f"[현재가API] 오류 code={stock_code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return None + return j.get("output") + + def inquire_multprice(self, stock_codes: List[str], max_per_call: int = 20): + """ + 다중 종목 현재가 조회 [intstock-multprice] + - 한투 API: /uapi/domestic-stock/v1/quotations/intstock-multprice + - 성공 시 {종목코드: output딕셔너리} 반환, 실패 시 None (오류 시 rt_cd/msg_cd/msg1 로깅) + - TR_ID: FHKST01010600 + - ⚠️ 배치 응답에는 stck_oprc(시가)/stck_hgpr(고가)/stck_lwpr(저가)가 없을 수 있음(API 스펙). + 시가·고가·저가 필요 시 단건 inquire_price() 사용. + """ + if not stock_codes: + return None + codes = list(stock_codes)[: max_per_call * 10] + result = {} + for i in range(0, len(codes), max_per_call): + chunk = codes[i : i + max_per_call] + iscd = ",".join(chunk) + path = "/uapi/domestic-stock/v1/quotations/intstock-multprice" + tr_id = "FHKST01010600" + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": iscd} + r = self._get(path, tr_id, params) + if r.status_code != 200: + try: + body_preview = (r.text or "")[:300] + except Exception: + body_preview = "" + logger.warning( + f"[다중시세API] HTTP 실패 status={r.status_code} body={body_preview}" + ) + continue + try: + j = r.json() + except Exception as e: + logger.warning(f"[다중시세API] JSON 파싱 실패 exception={e}") + continue + if j.get("rt_cd") != "0": + logger.warning( + f"[다중시세API] 오류 rt_cd={j.get('rt_cd')} " + f"msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + continue + out = j.get("output") + if out is None: + continue + if isinstance(out, list): + for item in out: + if isinstance(item, dict): + code = ( + item.get("stck_shrn_iscd") + or item.get("rsym") + or item.get("FID_INPUT_ISCD") + or item.get("mksc_shrn_iscd") + ) + if code: + result[code] = item + elif isinstance(out, dict): + code = ( + out.get("stck_shrn_iscd") + or out.get("rsym") + or out.get("FID_INPUT_ISCD") + ) + if code: + result[code] = out + time.sleep(random.uniform(0.2, 0.5)) + return result if result else None + + def inquire_prices_batch(self, stock_codes: List[str]): + """ + 다중 종목 현재가 일괄 조회 + - intstock-multprice API 우선 시도 후, 실패 시 순차 조회(inquire_price)로 fallback + - 순차 조회 시 종목당 0.3~0.6초 딜레이 + - 배치 응답에는 stck_prpr(현재가)는 있으나, stck_oprc/stck_hgpr/stck_lwpr 는 없을 수 있음. + 시가·고가·저가 필요 시 배치 대신 종목별 inquire_price() 사용(개미털기 스캔은 이미 단건 사용). + """ + if not stock_codes: + return {} + multi = self.inquire_multprice(stock_codes) + if multi: + return multi + result = {} + for code in stock_codes: + try: + price_data = self.inquire_price(code) + if price_data: + result[code] = price_data + time.sleep(random.uniform(0.3, 0.6)) + except Exception as e: + logger.warning(f"종목 조회 실패({code}) exception={e!r}") + continue + return result + + def get_account_balance(self): + """계좌 잔고 조회 [v1_국내주식-010]. 모의/실전에 따라 TR ID 분기 (EGW2004 방지).""" + if self.mock: + tr_id = "VTTC8434R" + else: + tr_id = "TTTC8434R" + params = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "01", # 01: 예수금/잔고 요약 (output2에 예수금) - 블로그·한투 문서 기준 + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "00", # 00: 조회 (블로그 기준) + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + } + try: + logger.info(f"💵 [예수금] 잔고 조회 요청: TR={tr_id}, CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}") + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance", + tr_id, + params, + ) + if r.status_code != 200: + logger.warning( + f"💵 [예수금] 잔고 API HTTP 오류: status={r.status_code}, body={getattr(r, 'text', '')[:200]} | " + f"TR={tr_id} (모의={self.mock}), CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}. " + f"EGW2004 시 모의면 VTTC8434R/실전이면 TTTC8434R 확인" + ) + return None + j = r.json() + if j.get("rt_cd") != "0": + msg1 = (j.get("msg1") or "")[:150] + msg_cd = j.get("msg_cd", "") + logger.error( + f"💵 [예수금] 잔고 API 응답 오류: rt_cd={j.get('rt_cd')}, msg_cd={msg_cd}, msg1={msg1} | " + f"요청 파라미터: TR={tr_id}, CANO={self.acc_no}({len(self.acc_no)}자리), " + f"ACNT_PRDT_CD={self.acc_code}({len(self.acc_code)}자리), 모의={self.mock} | " + f"전체 응답: {j}" + ) + if "OPSQ2000" in str(msg_cd) or "INVALID_CHECK_ACNO" in msg1: + logger.error( + "💵 [예수금] OPSQ2000 = 계좌번호 검증 실패. " + f"모의투자 서버({self.base_url})에 계좌번호 CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}가 등록되어 있는지 확인. " + f"한투 모의투자 앱/웹에서 계좌번호 확인 필요. DB의 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK 값 확인." + ) + return None + logger.debug(f"💵 [예수금] 잔고 조회 성공: output2 keys={list(j.get('output2', [{}])[0].keys()) if isinstance(j.get('output2'), list) and j.get('output2') else []}") + return j + except Exception as e: + logger.error(f"💵 [예수금] 잔고 조회 예외: {e} | CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}") + return None + + def get_account_evaluation(self): + """계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R.""" + if self.mock: + tr_id = "VTTC8494R" + else: + tr_id = "TTTC8494R" + try: + logger.info(tr_id) + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl", + 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": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"계좌 평가 조회 실패: {e}") + return None + + def get_order_history(self, start_date=None, end_date=None): + """주문 체결 내역 조회 [v1_국내주식-012] (모의=VTTC8001R, 실전=TTTC8001R)""" + try: + if self.mock: + tr_id = "VTTC8001R" + else: + tr_id = "TTTC8001R" + if not start_date: + start_date = dt.now().strftime("%Y%m%d") + if not end_date: + end_date = dt.now().strftime("%Y%m%d") + + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", + tr_id, + { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "INQR_STRT_DT": start_date, + "INQR_END_DT": end_date, + "SLL_BUY_DVSN_CD": "00", # 00:전체 + "INQR_DVSN": "00", # 00:역순 + "PDNO": "", + "CCLD_DVSN": "00", # 00:전체 + "ORD_GNO_BRNO": "", + "ODNO": "", + "INQR_DVSN_3": "00", + "INQR_DVSN_1": "", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"주문 내역 조회 실패: {e}") + return None + + def get_volume_surge_stocks(self, market="J", min_volume_rate="50", limit=50): + """거래량 급증 종목 조회 [v1_국내주식-023]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + { + "FID_COND_MRKT_DIV_CODE": market, + "FID_INPUT_ISCD": "", + "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + }, + ) + # 실제로는 거래량 급증 API를 사용해야 하지만, 여기서는 예시로 현재가 조회 활용 + # 실제 구현 시: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice 사용 + return [] + except Exception as e: + logger.error(f"거래량 급증 종목 조회 실패: {e}") + return [] + + def get_top_price_movers(self, market="J", sort_type="1", limit=50): + """등락률 상위 종목 조회 [v1_국내주식-027]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + { + "FID_COND_MRKT_DIV_CODE": market, + "FID_INPUT_ISCD": "", + "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + }, + ) + # 실제 구현 필요 + return [] + except Exception as e: + logger.error(f"등락률 상위 조회 실패: {e}") + return [] + + def get_investor_trend(self, stock_code, days=5): + """외국인/기관 매매 동향 조회""" + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + tr_id = "FHKST03010100" + try: + # 일봉 데이터에서 외국인/기관 정보 추출 + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=days + 10) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": "D", + "FID_ORG_ADJ_PRC": "1", + } + logger.debug(f"[투자자동향] 호출 code={stock_code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[투자자동향] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} status={r.status_code}") + return None + + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[투자자동향] 오류 code={stock_code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return None + + out2 = j.get("output2", []) + if not out2: + return None + + # 최근 N일 외국인/기관 순매수 합계 + foreign_sum = 0 + org_sum = 0 + + for item in out2[:days]: + try: + foreign_raw = item.get("frgn_ntby_qty") or item.get("frgn_ntby_shnu") or "0" + foreign_net = int(float(str(foreign_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(foreign_raw).startswith("-"): + foreign_net = -foreign_net + + org_raw = item.get("orgn_ntby_qty") or "0" + org_net = int(float(str(org_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(org_raw).startswith("-"): + org_net = -org_net + + foreign_sum += foreign_net + org_sum += org_net + except: + continue + + return { + "foreign_net_buy": foreign_sum, + "org_net_buy": org_sum, + "total_net_buy": foreign_sum + org_sum, + } + except Exception as e: + logger.error(f"외국인/기관 동향 조회 실패({stock_code}): {e}") + return None + + def get_daily_chart(self, code, limit=10): + """일봉 차트 조회 [v1_국내주식-017] - 거래대금(대/중/소형) 계산용""" + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + tr_id = "FHKST03010100" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=limit + 30) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": "D", + "FID_ORG_ADJ_PRC": "1", + } + logger.debug(f"[일봉차트] 호출 code={code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[일봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[일봉차트] 오류 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return pd.DataFrame() + data = j.get("output2", []) + if not data: + return pd.DataFrame() + rows = [] + for item in data: + try: + rows.append({ + "date": item.get("stck_bsop_date", ""), + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except Exception: + continue + if not rows: + return pd.DataFrame() + df = pd.DataFrame(rows) + df = df.sort_values("date").reset_index(drop=True) + return df.tail(limit) + except Exception as e: + logger.error(f"일봉 조회 실패({code}): {e}") + return pd.DataFrame() + + def buy_order(self, code, qty, price=0, order_type="01"): + """ + 매수 주문 (모의=VTTC0802U, 실전=TTTC0802U) + + Args: + code: 종목코드 + qty: 수량 + price: 가격 (0이면 시장가) + order_type: 주문구분 + - "01": 시장가 + - "00": 지정가 + - "05": 조건부지정가 + - "06": 최유리지정가 + - "07": 최우선지정가 + - "10": 보통(IOC) + - "13": 시장가(IOC) + - "16": 최유리(IOC) + - "20": 보통(FOK) + - "23": 시장가(FOK) + - "26": 최유리(FOK) + """ + try: + if self.mock: + tr_id = "VTTC0802U" + else: + tr_id = "TTTC0802U" + if price > 0: + ord_unpr = str(price) + else: + ord_unpr = "0" + path = "/uapi/domestic-stock/v1/trading/order-cash" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": ord_unpr, + } + logger.debug(f"[매수주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}") + r = self._post(path, tr_id, body, use_hashkey=True) + if r.status_code != 200: + logger.error(f"[매수주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매수 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error( + f"[매수주문] 실패 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return False + except Exception as e: + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def buy_market_order(self, code, qty): + """시장가 매수 주문 (간편 메서드)""" + return self.buy_order(code, qty, price=0, order_type="01") + + def sell_order(self, code, qty, price=0, order_type="01"): + """ + 매도 주문 (모의=VTTC0801U, 실전=TTTC0801U) + + Args: + code: 종목코드 + qty: 수량 + price: 가격 (0이면 시장가) + order_type: 주문구분 (buy_order와 동일) + """ + try: + if self.mock: + tr_id = "VTTC0801U" + else: + tr_id = "TTTC0801U" + if price > 0: + ord_unpr = str(price) + else: + ord_unpr = "0" + path = "/uapi/domestic-stock/v1/trading/order-cash" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": ord_unpr, + } + logger.debug(f"[매도주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}") + r = self._post(path, tr_id, body, use_hashkey=True) + if r.status_code != 200: + logger.error(f"[매도주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매도 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error( + f"[매도주문] 실패 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return False + except Exception as e: + logger.error(f"매도 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문 (간편 메서드)""" + return self.sell_order(code, qty, price=0, order_type="01") + + def get_minute_chart(self, code, period="3", limit=100): + """분봉 차트 조회 [v1_국내주식-017]""" + path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + tr_id = "FHKST03010200" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=1) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + # 분봉 코드: 1분=1, 3분=3, 5분=5, 10분=10, 30분=30, 60분=60 + period_map = {"1": "1", "3": "3", "5": "5", "10": "10", "30": "30", "60": "60"} + period_code = period_map.get(str(period), "3") + + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_HOUR_1": start_ymd, + "FID_INPUT_HOUR_2": end_ymd, + "FID_PW_DATA_INCU_YN": "Y", + "FID_ETC_CLS_CODE": "", # 기타분류코드 (필수 파라미터, 빈 값 가능) + } + logger.debug(f"[분봉차트] 호출 code={code} period={period} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[분봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[분봉차트] 오류 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return pd.DataFrame() + + data = j.get("output2", []) + if not data: + return pd.DataFrame() + + rows = [] + for item in data: + try: + rows.append({ + "time": item.get("stck_bsop_date", ""), + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except: + continue + + if not rows: + return pd.DataFrame() + + df = pd.DataFrame(rows) + df = df.sort_values("time").reset_index(drop=True) + + # 기술적 지표 추가 + if len(df) >= 14: + delta = df["close"].diff(1) + gain = delta.where(delta > 0, 0).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss.replace(0, float("nan")) + df["RSI"] = 100 - (100 / (1 + rs)) + + if len(df) >= 20: + df["MA20"] = df["close"].rolling(window=20).mean() + + return df.tail(limit) + except Exception as e: + logger.error(f"분봉 조회 실패({code}): {e}") + return pd.DataFrame() + + def get_orderbook(self, stock_code): + """호가 조회 [v1_국내주식-009]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", + "FHKST01010200", + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j.get("output") + except Exception as e: + logger.error(f"호가 조회 실패({stock_code}): {e}") + return None + + # ============================================================ + # 순위분석 API (키움 봇과 동일한 로직 + 레버리지/스팩/ETN 제외 옵션) + # ============================================================ + + @staticmethod + def _is_valid_stock(name: str, code: str) -> bool: + """ + 종목 필터링 (키움 kiwoom_trader_ver2와 동일, ETF는 포함) + - 스팩, ETN, 우선주, 레버리지, 인버스 등만 제외 (ETF는 위험도 낮아 포함) + """ + if not code or len(code) != 6 or not code.isdigit(): + return False + name = (name or "").strip() + exclude = [ + "스팩", "SPAC", "ETN", "W", "ELW", "채권", + "레버리지", "인버스", "곱버스", "선물", "콜", "풋", + "2X", "3X", "합성", "H", "B", + ] + # ETF는 exclude 목록에 없음 → 일반 주식·ETF 모두 통과 + if any(k in name for k in exclude): + return False + if name.endswith("우") or name.endswith("우B"): + return False + return True + + def _filter_rank_by_valid_stock(self, rank_list: list) -> list: + """랭크 API 응답 리스트에서 스팩/ETN/레버리지 등 제외 (키움 옵션과 동일)""" + if not rank_list: + return [] + filtered = [] + for item in rank_list: + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + name = (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip() + if self._is_valid_stock(name, code): + filtered.append(item) + return filtered + + def _fetch_volume_rank_paged( + self, + market: str, + blng_cls_code: str, + limit: int, + exclude_spec_etn_leverage: bool, + ) -> list: + """ + 거래량순위 API 연속 조회 (tr_cont)로 limit건까지 수집. + API가 한 번에 20~30건만 주므로, tr_cont='M'이면 다음 페이지 요청 반복. + """ + path = "/uapi/domestic-stock/v1/quotations/volume-rank" + tr_id = "FHPST01710000" + base = { + "FID_COND_MRKT_DIV_CODE": market, + "FID_COND_SCR_DIV_CODE": "20171", + "FID_INPUT_ISCD": "0000", + "FID_DIV_CLS_CODE": "0", + "FID_BLNG_CLS_CODE": blng_cls_code, + "FID_TRGT_CLS_CODE": "111111111", + "FID_TRGT_EXLS_CLS_CODE": "0000000000", + "FID_INPUT_PRICE_1": "0", + "FID_INPUT_PRICE_2": "0", + "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] + except Exception as e: + logger.debug(f"거래량순위 연속 조회 실패: {e}") + return accumulated[:limit] + + def get_volume_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """ + 거래량순위 조회 [v1_국내주식-047] (연속 조회로 limit건까지 수집) + """ + try: + output = self._fetch_volume_rank_paged( + market=market, + blng_cls_code="0", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + return output + except Exception as e: + logger.debug(f"거래량순위 조회 실패: {e}") + return [] + + def get_price_change_rank( + self, + market: str = "J", + sort_type: str = "1", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """ + 등락률순위 조회 (동일 volume-rank API, FID_BLNG_CLS_CODE로 등락 구분) + sort_type: "1"=상승률 상위, "2"=하락률 상위(낙폭 큰 종목, N자 망치봉 스캔에 유리) + """ + # 한투 volume-rank API: 4=등락률(상승), 5=등락률(하락). 미지원 시 빈값/에러 가능. + blng = "5" if sort_type == "2" else "4" + try: + out = self._fetch_volume_rank_paged( + market=market, + blng_cls_code=blng, + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + if out: + return out + except Exception as e: + logger.debug(f"등락률순위(blng={blng}) 조회 실패: {e}") + # API가 4/5 미지원이면 기존처럼 거래량순위로 fallback (상승 위주 후보 확보) + if sort_type != "2": + return self.get_volume_rank(market=market, limit=limit, exclude_spec_etn_leverage=exclude_spec_etn_leverage) + return [] + + def get_price_decline_rank( + self, + market: str = "J", + limit: int = 100, + exclude_spec_etn_leverage: bool = True, + ): + """하락률 순위(낙폭 큰 종목) 조회. N자 망치봉/개미털기 스캔 유니버스 확대용.""" + return self.get_price_change_rank( + market=market, + sort_type="2", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + + def get_trading_value_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """거래대금순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=3""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="3", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"거래대금순위 조회 실패: {e}") + return [] + + def get_turnover_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """회전율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=2""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="2", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"회전율순위 조회 실패: {e}") + return [] + + def get_volume_growth_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """거래증가율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=1""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="1", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"거래증가율순위 조회 실패: {e}") + return [] + + def get_execution_strength_rank( + self, + market: str = "J", + limit: int = 200, + exclude_spec_etn_leverage: bool = True, + ): + """체결강도 상위 순위 조회 (FHPST01710000, FID_BLNG_CLS_CODE=6). 매수세 강한 종목 필터용.""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="6", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"체결강도순위 조회 실패: {e}") + return [] + + def get_execution_strength_map(self, market: str = "J", limit: int = 200): + """ + 체결강도 상위 API 조회 후 종목코드 -> 체결강도 값 매핑 반환. + output 필드: cntr_str(체결강도) 등 한투 문서 기준으로 파싱. 미제공 시 0. + """ + strength_map = {} + try: + rows = self.get_execution_strength_rank(market=market, limit=limit, exclude_spec_etn_leverage=True) + for item in (rows or []): + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + if not code or len(code) != 6: + continue + # 한투 volume-rank 체결강도: cntr_str 또는 유사 필드 (문서 확인 후 조정) + raw = item.get("cntr_str") or item.get("exec_str") or item.get("strg_rt") or item.get("prdy_ctrt") or "" + try: + strength = float(str(raw).replace(",", "").strip()) if raw else 0 + except (ValueError, TypeError): + strength = 0 + strength_map[code] = strength + except Exception as e: + logger.debug(f"체결강도 맵 조회 실패: {e}") + return strength_map + + +# ============================================================ +# Mattermost 봇 클래스 +# ============================================================ +class MattermostBot: + """Mattermost 알림 봇""" + 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 MM_CONFIG_FILE.exists(): + 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 + + +# ============================================================ +# 단타 트레이딩 봇 +# ============================================================ +class ShortTradingBot: + """단타용 트레이딩 봇 - 개미털기(눌림목) 전략""" + def __init__(self): + self.db = db + self.client = KISClient() + + # Mattermost 초기화 + self.mm = MattermostBot() + # 단타 봇 전용 채널(alias) 우선 사용, 없으면 기본 채널 사용 + self.mm_channel = MM_CHANNEL_SHORT + + # ML 예측 초기화 (선택적) + self.ml_predictor = None + if ML_AVAILABLE: + try: + self.ml_predictor = MLPredictor(db_path=str(SCRIPT_DIR / "quant_bot.db")) + if self.ml_predictor.should_retrain(): + self.ml_predictor.train_model(retrain=True) + except Exception as e: + logger.warning(f"⚠️ ML 예측 초기화 실패: {e}") + + # 전략 파라미터 (DB·env 연동) + # ============================================================ + # [손절/익절 설정] - 대형주 기준 현실적인 값 + # ============================================================ + # 손절 라인: -4% (주가 기준, 대형주는 노이즈 감안해 넉넉히) + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04) # -4% 손절 (대형주 기준) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05) + self.max_stocks = get_env_int("MAX_STOCKS", 3) + # 개미털기 필터: 낙폭 3% 이상 + 회복률 50% 이상 통과 (후보가 너무 적으면 MIN_DROP_RATE=0.02, MIN_RECOVERY_RATIO_SHORT=0.32 등 완화) + self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03) + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5) + + # ATR 기반 손절/익절 배수 (변동성 기반 동적 손절가/목표가) + self.stop_atr_multiplier = get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5) + self.target_atr_multiplier = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 8.0) + + # 24시간 보유 전략 (N자 패턴 등 다음날 상승 가능성 있는 종목) + self.min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) # 최소 보유 시간 (기본 24시간) + + # ============================================================ + # [리스크 관리 설정] - 변동성 역가중 (Volatility Inverse Weighting) + # ============================================================ + self.risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.01) # 1% (계좌 기준) + self.kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25) # 쿼터 켈리 (0.25) + self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.15) # 종목당 최대 15% + self.min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 50000) # 최소 5만원 + + # RiskManager 초기화 (변동성 역가중 + 쿼터 켈리 매수 금액 계산) + self.risk_mgr = None + if RISK_MANAGER_AVAILABLE: + # 켈리 공식 사용 여부 (기본 ON - 쿼터 켈리 0.25 적용) + use_kelly = get_env_bool("USE_KELLY_FORMULA", True) + self.risk_mgr = RiskManager( + risk_pct_per_trade=self.risk_pct_per_trade, + max_position_pct=self.max_position_pct, + min_position_amount=self.min_position_amount, + use_kelly=use_kelly, + kelly_multiplier=self.kelly_multiplier, # 쿼터 켈리 0.25 + ) + kelly_status = f"켈리{'ON' if use_kelly else 'OFF'}(배율={self.kelly_multiplier})" + logger.info(f"✅ RiskManager 활성화: 변동성 역가중 + {kelly_status}") + else: + logger.warning("⚠️ RiskManager 미사용: 고정 슬롯 금액 방식으로 폴백") + + # ML 신호 필터링 설정 + self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False) + self.ml_min_probability = get_env_float("ML_MIN_PROBABILITY", 0.57) + + # 리포트 플래그 + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.ai_report_sent = False + + # 자산 추적 + self.today_date = dt.now().strftime("%Y-%m-%d") + self.start_day_asset = 0 + self.current_total_asset = 0 + self.current_cash = 0 + self.d2_excc_amt = 0 # D+2 예수금 (output2 prvs_rcdl_excc_amt) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + + # DB에서 활성 트레이드 로드 + self.holdings = {} + active_trades = self.db.get_active_trades() + for code, trade in active_trades.items(): + self.holdings[code] = { + "buy_price": trade.get("avg_buy_price", 0), + "qty": trade.get("current_qty", 0), + "buy_time": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), + "name": trade.get("name", code), + } + + # 초기 자산 조회 + self._update_assets() + + # 비동기 태스크 관리 + self._universe_task = None + self._report_task = None + self._asset_task = None + self.is_first_run = True + + def _seconds_until_next_5min(self): + """다음 5분 정각까지 남은 초 계산""" + now = dt.now() + next_min = ((now.minute // 5) + 1) * 5 + if next_min >= 60: + next_time = now.replace(hour=now.hour + 1, minute=0, second=0, microsecond=0) + else: + next_time = now.replace(minute=next_min, second=0, microsecond=0) + return (next_time - now).total_seconds() + + def update_universe(self): + """ + 유니버스 업데이트 (5분마다 호출) - 키움 봇과 동일한 복합 스캔 로직 + - 개미털기 우선 (원본 점수 유지) + - 외국인/거래량/상승률/기관 추가 (보너스 점수) + - 강도 순으로 정렬 → Top 30 + """ + logger.info(f"🔄 [유니버스 업데이트] 시작 | 예수금: {self.current_cash:,.0f}원") + logger.info("📡 [복합 스캔] 개미털기 우선 + 4가지 보너스 소스") + + try: + # 매수 가능 금액 계산 + if self.max_stocks > 0: + slot_money = int(self.current_cash * 0.9 / self.max_stocks) + else: + slot_money = 100000 + + all_candidates = {} # {code: {name, price, base_score, bonus_score, total_score}} + + # 이번 스캔 주기용으로 후보 테이블 비움 (통과 시마다 add_target_candidate로 채워짐) + self.db.update_target_candidates([]) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 1. 개미털기 (눌림목) - 원본 점수 100% 유지! (통과 시마다 즉시 add_target_candidate) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + ant_shaking = self.scan_ant_shaking_candidates(max_candidates=50) + logger.info(f" ✅ [개미털기] {len(ant_shaking)}개 수집 (강도 원본 유지)") + for item in ant_shaking: + code = item['code'] + all_candidates[code] = { + 'code': code, + 'name': item['name'], + 'price': item['price'], + 'base_score': item['score'], # 개미털기 원본 점수 (100%) + 'bonus_score': 0.0, # 보너스 점수 (추가) + 'from_ant': True, + 'drop_rate': item.get('drop_rate', 0), + 'recovery': item.get('recovery', 0), + } + except Exception as e: + logger.warning(f" ⚠️ [개미털기] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 2. 거래량순위 - 보너스 +0.3 (순위별 가산점) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + volume_rank = self.client.get_volume_rank(market="J", limit=50) + logger.info(f" ✅ [거래량순위] {len(volume_rank)}개 수집") + for idx, item in enumerate(volume_rank): + code = item.get('stk_cd', '').strip() or item.get('code', '').strip() + if not code or len(code) != 6: + continue + bonus = (50 - idx) / 50.0 * 0.3 # 순위별 보너스 (최대 +0.3) + + 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))) + if current_price > 0 and 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, + } + except Exception as e: + logger.warning(f" ⚠️ [거래량순위] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 3. 등락률순위 (상승률) - 보너스 +0.2 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + price_movers = self.client.get_price_change_rank(market="J", sort_type="1", limit=30) + logger.info(f" ✅ [등락률순위] {len(price_movers)}개 수집") + for idx, item in enumerate(price_movers): + code = item.get('stk_cd', '').strip() or item.get('code', '').strip() + if not code or len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.2 # 순위별 보너스 (최대 +0.2) + + 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))) + if current_price > 0 and 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, + } + except Exception as e: + logger.warning(f" ⚠️ [등락률순위] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 4. 거래대금순위 - 보너스 +0.2 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + value_rank = self.client.get_trading_value_rank(market="J", limit=30) + logger.info(f" ✅ [거래대금순위] {len(value_rank)}개 수집") + for idx, item in enumerate(value_rank): + code = item.get('stk_cd', '').strip() or item.get('code', '').strip() + if not code or len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.2 # 순위별 보너스 (최대 +0.2) + + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + except Exception as e: + logger.warning(f" ⚠️ [거래대금순위] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 5. 외국인/기관 순매수 - 보너스 +0.3 (투자자 동향 기반) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 개미털기 후보에 대해서만 투자자 동향 확인 (API 호출 최소화) + investor_check_count = 0 + for code, candidate in list(all_candidates.items()): + if candidate.get('from_ant') and investor_check_count < 10: # 최대 10개만 체크 + try: + investor_trend = self.client.get_investor_trend(code, days=2) + investor_check_count += 1 + if investor_trend: + total_net = investor_trend.get("total_net_buy", 0) + if total_net > 20000: + candidate['bonus_score'] += 0.3 # 강한 매수세 + elif total_net > 5000: + candidate['bonus_score'] += 0.15 # 매수세 + except Exception as e: + logger.debug(f"투자자 동향 조회 실패({code}): {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 최종 점수 계산 및 정렬 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + final_candidates = [] + for code, item in all_candidates.items(): + total_score = item['base_score'] + item['bonus_score'] + final_candidates.append({ + 'code': code, + 'name': item['name'], + 'price': item['price'], + 'score': total_score, + 'base_score': item['base_score'], + 'bonus_score': item['bonus_score'], + 'from_ant': item.get('from_ant', False), + 'drop_rate': item.get('drop_rate', 0), + 'recovery': item.get('recovery', 0), + }) + + # 강도 순 정렬 (total_score 기준) + final_candidates.sort(key=lambda x: x['score'], reverse=True) + + # 강도 4.0 이상만 필터링 (키움 봇과 동일) + filtered = [c for c in final_candidates if c['score'] >= 4.0] + + if not filtered: + logger.warning(f" ⚠️ 강도 4.0 이상 후보 없음 (전체: {len(final_candidates)}개)") + # 강도 낮춰서라도 상위 30개 저장 + filtered = final_candidates[:30] + + # DB 저장: 스캔 중 통과 시마다 이미 add_target_candidate()로 실시간 저장됨 → 여기서는 전체 교체 안 함 + # 스캔 완료 후에는 정렬만 해서 로그만 찍음 (매매 루프는 DB에서 실시간으로 후보 읽음) + logger.info(f" 💾 매수 후보군: 스캔 중 통과 즉시 저장 완료, 최종 후보 {len(filtered)}개 (DB 반영됨, 정렬 순 로그만 출력)") + + # Top 5 상세 로그 (강도 순, 종목명 표시) + logger.info(f" 🔝 [유니버스 Top 5] (강도 순)") + + # Top 5 종목의 전일 대비 정보 조회 (API 문서: stck_prdy_clpr = 전일 종가) + def _safe_float(val): + if not val: + return 0.0 + try: + return abs(float(str(val).replace(",", "").strip() or 0)) + except: + return 0.0 + + prev_day_map = {} + for x in filtered[:5]: + code = x["code"] + name = x.get("name", code) + try: + price_data = self.client.inquire_price(code) + if not price_data: + logger.info(f" ⚠️ 전일대비: {name} {code} API응답없음") + continue + current_price = _safe_float(price_data.get("stck_prpr")) or _safe_float(x.get("price", 0)) + prev_close = _safe_float(price_data.get("stck_prdy_clpr")) # API 문서 필드명 그대로 사용 + if prev_close <= 0: # 없으면 일봉에서 전일 close + df_daily = self.client.get_daily_chart(code, limit=3) + if not df_daily.empty and len(df_daily) >= 2: + prev_close = _safe_float(df_daily["close"].iloc[-2]) + logger.info(f" 📊 전일대비: {name} {code} 일봉에서 전일종가 조회 성공 ({prev_close:,.0f}원)") + else: + logger.info(f" ⚠️ 전일대비: {name} {code} 일봉데이터없음 (len={len(df_daily) if not df_daily.empty else 0})") + if prev_close > 0 and current_price > 0: + change = current_price - prev_close + change_pct = (change / prev_close) * 100 + prev_day_map[code] = (change, change_pct) + else: + logger.info(f" ⚠️ 전일대비: {name} {code} prev_close={prev_close}, current={current_price}") + time.sleep(random.uniform(0.2, 0.3)) + except Exception as e: + logger.warning(f" ⚠️ 전일대비 조회 실패({name} {code}): {e}") + + for i, x in enumerate(filtered[:5], 1): + base = x.get('base_score', 0) + bonus = x.get('bonus_score', 0) + total = x['score'] + drop_pct = x.get('drop_rate', 0) * 100 + recovery_pct = x.get('recovery', 0) * 100 + if x.get('from_ant'): + source = "개미털기" + else: + source = "랭킹" + if x.get('ml_probability'): + ml_info = f" | ML {x.get('ml_probability', 0):.1%}" + else: + ml_info = "" + + # 전일 대비 등락률 표시 + prev_day_info = "" + code = x['code'] + if code in prev_day_map: + change, change_pct = prev_day_map[code] + sign = "+" if change >= 0 else "" + prev_day_info = f" | 어제보다 {sign}{change:,.0f}원 ({sign}{change_pct:.2f}%)" + + logger.info( + f" {i}. {x['name']} {x['code']}: " + f"강도 {total:.1f} (기본 {base:.1f} + 보너스 {bonus:.1f}) | " + f"낙폭 {drop_pct:.1f}% | 회복 {recovery_pct:.0f}% | {source}{ml_info}{prev_day_info}" + ) + + logger.info(f" ✅ 최종 후보: {len(filtered)}개 (강도 4.0 이상: {len([c for c in final_candidates if c['score'] >= 4.0])}개)") + + except Exception as e: + logger.error(f"❌ 유니버스 업데이트 실패: {e}") + import traceback + logger.error(traceback.format_exc()) + + async def _universe_scan_scheduler(self): + """5분마다 정각에 유니버스 스캔 실행 (비동기 백그라운드)""" + loop = asyncio.get_event_loop() + while True: + try: + if self.is_first_run: + wait_sec = 0 # 첫 실행은 즉시 + else: + wait_sec = max(0, self._seconds_until_next_5min()) + if wait_sec > 0: + await asyncio.sleep(wait_sec) + now = dt.now() + logger.info(f"🔄 [스캔 주기] 정각 스캔 시작 | 시각:{now.hour:02d}:{now.minute:02d}:{now.second:02d}") + # 동기 함수를 executor에서 실행 (메인 루프 블로킹 방지) + await loop.run_in_executor(None, self.update_universe) + self.is_first_run = False + await asyncio.sleep(5) # 스캔 직후 5초 대기 (과부하 방지) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [스캔 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _report_scheduler(self): + """리포트 전송 스케줄러 (비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 13:00 - 오전 리포트 + AI 리포트 + if now.hour == 13 and now.minute == 0 and not self.morning_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_morning_report) + await loop.run_in_executor(None, self.send_ai_report) + + # 15:15 - 장마감 전 리포트 + elif now.hour == 15 and now.minute == 15 and not self.closing_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_closing_report) + + # 15:35 - 최종 리포트 + elif now.hour == 15 and now.minute == 35 and not self.final_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_final_report) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [리포트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _asset_update_scheduler(self): + """자산 정보 업데이트 스케줄러 (30분마다, 비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 30분마다 자산 업데이트 + if now.minute % 30 == 0: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._update_assets) + await asyncio.sleep(60) # 업데이트 후 1분 대기 (중복 방지) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [자산 업데이트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + def _update_assets(self): + """자산 정보 업데이트""" + try: + balance = self.client.get_account_balance() + if balance is None: + logger.warning( + "💵 [예수금] get_account_balance가 None 반환 → 예수금 갱신 스킵 " + "(토큰·계좌·TR ID 확인. 모의=VTTC8434R, 실전=TTTC8434R)" + ) + return + # 한투 API: output1=주식 잔고(종목별), output2=예수금 관련(dnca_tot_amt 등) - 블로그·문서 기준 + def _parse_amt(v): + if v is None or str(v).strip() == "": + return None + return float(str(v).replace(",", "").strip()) + def _cash_block(obj): + if not obj: + return {} + if isinstance(obj, list) and obj: + return obj[0] + if isinstance(obj, dict): + return obj + return {} + out2 = _cash_block(balance.get("output2")) + if isinstance(balance.get("output1"), dict): + out1 = balance.get("output1", {}) + else: + out1 = {} + ord_psbl_val = _parse_amt(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash")) + dnca_tot_val = _parse_amt(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0 + # D+2 예수금 (전일 정산 수령 예정 금액) - 한투 output2 prvs_rcdl_excc_amt + prvs_rcdl = _parse_amt(out2.get("prvs_rcdl_excc_amt")) + if prvs_rcdl is not None: + self.d2_excc_amt = prvs_rcdl + else: + self.d2_excc_amt = 0 + if ord_psbl_val is not None: + self.current_cash = ord_psbl_val + logger.info( + f"💵 [예수금] 주문가능(ord_psbl_cash)={self.current_cash:,.0f}원 | " + f"dnca_tot_amt={dnca_tot_val:,.0f} | D+2예수금(prvs_rcdl_excc_amt)={self.d2_excc_amt:,.0f}원" + ) + else: + self.current_cash = dnca_tot_val + logger.info( + f"💵 [예수금] dnca_tot_amt={self.current_cash:,.0f}원 | " + f"D+2예수금(prvs_rcdl_excc_amt)={self.d2_excc_amt:,.0f}원 (output2 keys={list(out2.keys()) if out2 else []})" + ) + # 보유 종목 평가액 계산 + holdings_value = 0 + for code, holding in self.holdings.items(): + 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["qty"] + self.current_total_asset = self.current_cash + holdings_value + if self.start_day_asset == 0: + self.start_day_asset = self.current_total_asset + except Exception as e: + logger.error(f"자산 정보 업데이트 실패: {e}") + + def _update_account_light(self, profit_val=0): + """ + 경량 계좌 갱신 (매수/매도 직후 즉시 호출!) + - API 부하를 줄이기 위해 예수금 + 보유 종목 평가액만 빠르게 계산 + - 총자산 = 예수금 + 보유 종목 평가액 (+ 손익 반영) + """ + try: + balance = self.client.get_account_balance() + if balance is None: + logger.warning("💵 [예수금-경량] get_account_balance None → 예수금 갱신 스킵") + return False + def _parse_amt_light(v): + if v is None or str(v).strip() == "": + return None + return float(str(v).replace(",", "").strip()) + def _cash_block_light(obj): + if not obj: + return {} + if isinstance(obj, list) and obj: + return obj[0] + if isinstance(obj, dict): + return obj + return {} + out2 = _cash_block_light(balance.get("output2")) + if isinstance(balance.get("output1"), dict): + out1 = balance.get("output1", {}) + else: + out1 = {} + ord_psbl_val = _parse_amt_light(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash")) + dnca_tot_val = _parse_amt_light(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0 + prvs_rcdl = _parse_amt_light(out2.get("prvs_rcdl_excc_amt")) + if prvs_rcdl is not None: + self.d2_excc_amt = prvs_rcdl + if ord_psbl_val is not None: + new_cash = ord_psbl_val + else: + new_cash = dnca_tot_val + logger.info( + f"💵 [예수금-경량] 주문가능={new_cash:,.0f}원 (이전={self.current_cash:,.0f}) | D+2예수금={self.d2_excc_amt:,.0f}원" + ) + if new_cash > 0 or self.current_cash == 0: + self.current_cash = new_cash + # 보유 종목 평가액: output1=주식 잔고(종목별), output2=예수금 요약 (블로그 기준) + output1_list = balance.get("output1", []) + 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: + evlu_amt = float(item.get("evlu_amt", 0)) + holdings_value += evlu_amt + 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["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 + except Exception as e: + logger.error(f"❌ 경량 갱신 실패: {e}") + return False + + def _update_cash_only(self): + """예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)""" + return self._update_account_light(profit_val=0) + + def send_mm(self, msg): + """Mattermost 알림 전송""" + try: + self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + + def check_market_status(self): + """장 운영 시간 체크""" + # FORCE_MARKET_OPEN 플래그 확인 (테스트용) + force_open = get_env_bool("FORCE_MARKET_OPEN", False) + if force_open: + logger.debug("🔓 FORCE_MARKET_OPEN=true - 장 상태 무시하고 계속 진행") + return True + + # 정상 장 운영 시간 체크 + now = dt.now() + if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)): + return False + if now.weekday() >= 5: # 주말 + return False + return True + + def send_morning_report(self): + """오전 장 뜸할 때 리포트 (13:00)""" + if self.morning_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + msg = f"""📊 **[오전 장 현황 - 13:00]** +- 당일 시작: {self.start_day_asset:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 보유 종목: {len(self.holdings)}개""" + + self.send_mm(msg) + self.morning_report_sent = True + logger.info("📊 오전 리포트 전송 완료") + + def send_closing_report(self): + """장마감 전 리포트 (15:15)""" + if self.closing_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + msg = f"""📈 **[장마감 전 현황 - 15:15]** +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 현재 자산: {self.current_total_asset:,.0f}원 +- 보유 종목: {len(self.holdings)}개 +- 예수금(주문가능): {self.current_cash:,.0f}원 | D+2예수금: {self.d2_excc_amt:,.0f}원""" + + self.send_mm(msg) + self.closing_report_sent = True + logger.info("📈 장마감 전 리포트 전송 완료") + + def send_final_report(self): + """장마감 후 최종 리포트 (15:35)""" + if self.final_report_sent: + return + + self._update_assets() + + # 당일 손익 + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + # 누적 손익 + cumulative_pnl = self.current_total_asset - self.total_deposit + if self.total_deposit > 0: + cumulative_pnl_pct = cumulative_pnl / self.total_deposit * 100 + else: + cumulative_pnl_pct = 0 + + # 오늘 거래 내역 + today_trades = self.db.get_trades_by_date(self.today_date) + + msg = f"""🏁 **[장마감 최종 보고 - 15:35]** +━━━━━━━━━━━━━━━━━━━━ +📅 **당일 손익** +- 시작: {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}원 | D+2예수금: {self.d2_excc_amt:,.0f}원 +━━━━━━━━━━━━━━━━━━━━""" + + self.send_mm(msg) + self.final_report_sent = True + logger.info("🏁 장마감 최종 리포트 전송 완료") + + def send_ai_report(self): + """AI 분석 리포트 (13:00)""" + if self.ai_report_sent or not gemini_client: + return + + try: + # 최근 거래 내역 조회 + recent_trades = [] + try: + conn = self.db.conn + cursor = conn.execute(""" + SELECT code, name, buy_price, sell_price, qty, profit_rate, + realized_pnl, strategy, sell_reason, buy_date, sell_date, hold_minutes + FROM trade_history + ORDER BY id DESC + LIMIT 10 + """) + for row in cursor.fetchall(): + recent_trades.append({ + 'code': row[0], + 'name': row[1], + 'buy_price': row[2], + 'sell_price': row[3], + 'qty': row[4], + 'profit_rate': row[5], + 'realized_pnl': row[6], + 'strategy': row[7], + 'sell_reason': row[8], + 'buy_date': row[9], + 'sell_date': row[10], + 'hold_minutes': row[11] or 0 + }) + except Exception as e: + logger.error(f"거래 내역 조회 실패: {e}") + return + + # 현재 유니버스 상태 + db_candidates = self.db.get_target_candidates() + candidate_count = len(db_candidates) + + # 거래 내역이 없어도 유니버스 상태는 리포트에 포함 + if not recent_trades: + # 거래 내역 없을 때도 유니버스 상태 리포트 + summary = f"""📊 **현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: 없음""" + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +**현재 상태:** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: 없음 + +**현재 설정값:** +- 최대 보유: {self.max_stocks}개 +- 최소 낙폭: {self.min_drop_rate*100:.1f}% +- 최소 회복률: {self.min_recovery_ratio*100:.0f}% +- ML 사용: {self.use_ml_signal} +- ML 최소 승률: {self.ml_min_probability:.1%} + +**당신의 임무:** +1. 후보가 {candidate_count}개인 이유 분석 (필터 조건이 너무 까다로운지 등) +2. **수치 추천** (변수명=값 형식, 정확한 숫자 제시) + - 예: MIN_DROP_RATE=0.025 (2.5%) + - 예: MIN_RECOVERY_RATIO=0.40 (40%) +3. 예상 효과 + +**출력 형식:** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] + +## 💡 수치 추천 (DB 설정 변경) +- 변수명1=값1 (이유: ...) +- 변수명2=값2 (이유: ...) + +## 📈 예상 효과 +- [효과 1] +- [효과 2] +""" + + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) + analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") + + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** + +{summary} + +{analysis} + +--- +💬 단타 전략 최적화를 위한 AI 분석입니다. (수치 추천 포함) +""" + + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료 (거래 내역 없음, 유니버스 상태 포함)") + return + + # 통계 계산 + total = len(recent_trades) + wins = sum(1 for t in recent_trades if t['profit_rate'] > 0) + losses = total - wins + if total > 0: + win_rate = wins / total * 100 + else: + win_rate = 0 + avg_profit = sum(t['profit_rate'] for t in recent_trades) / total + total_pnl = sum(t['realized_pnl'] for t in recent_trades) + avg_hold = sum(t['hold_minutes'] for t in recent_trades) / total + + # AI 분석 + trades_text = "" + for i, t in enumerate(recent_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']} +""" + + # 현재 유니버스 상태 + db_candidates = self.db.get_target_candidates() + candidate_count = len(db_candidates) + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +**현재 상태:** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: {total}건 +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:,.0f}원 +- 평균 보유: {avg_hold:.0f}분 + +**최근 거래 내역:** +{trades_text} + +**현재 설정값:** +- 최대 보유: {self.max_stocks}개 +- 최소 낙폭: {self.min_drop_rate*100:.1f}% +- 최소 회복률: {self.min_recovery_ratio*100:.0f}% +- ML 사용: {self.use_ml_signal} +- ML 최소 승률: {self.ml_min_probability:.1%} + +**당신의 임무:** +1. 문제점 3가지 진단 (구체적으로) + - 특히 후보가 {candidate_count}개인 이유 분석 +2. **수치 추천** (변수명=값 형식, 정확한 숫자 제시) + - 예: MIN_DROP_RATE=0.025 (2.5%) + - 예: MIN_RECOVERY_RATIO=0.40 (40%) + - 예: MAX_STOCKS=5 +3. 예상 효과 + +**출력 형식:** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] +3. [구체적 문제 3] + +## 💡 수치 추천 (DB 설정 변경) +- 변수명1=값1 (이유: ...) +- 변수명2=값2 (이유: ...) + +## 📈 예상 효과 +- [효과 1] +- [효과 2] + +**간결하고 명확하게 답변하세요.** +""" + + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) + analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") + + summary = f"""📊 **현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: {total}건 +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:+,.0f}원 +- 평균 보유: {avg_hold:.0f}분""" + + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** + +{summary} + +{analysis} + +--- +💬 단타 전략 최적화를 위한 AI 분석입니다. (수치 추천 포함) +""" + + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료") + + except Exception as e: + logger.error(f"AI 리포트 생성 실패: {e}") + + def _fetch_scan_universe_from_api(self, max_codes=500): + """ + KIS API로 스캔 대상 종목 리스트 조회 (6소스 각 100건 → 최대 500개). + - 거래량·거래대금·회전율·등락률(상승)·등락률(하락)·거래증가율 각 100건 합산 후 중복 제거. + - 리스트는 DB에 저장하지 않음. 스캔 끝난 뒤 후보만 DB에 한 번에 인서트. + Returns: + list[dict]: [{"code": "006자리", "name": "종목명"}, ...] (중복 제거, 최대 max_codes개) + """ + def _code_from_item(item): + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + return code if code and len(code) == 6 else None + + def _name_from_item(item): + return ( + (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip() + or "" + ) + + scan_list = [] + seen = set() + + # 1) 거래량순위 100개 (키움은 거래대금+회전율만 사용, KIS는 거래량+거래대금+회전율 3가지로 풀 확대) + try: + time.sleep(random.uniform(0.5, 1.0)) + vol_list = self.client.get_volume_rank(market="J", limit=100) + for item in (vol_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래량순위 API → {len(vol_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래량순위 조회 실패: {e}") + + # 2) 거래대금순위 100개 (키움과 동일 소스) + try: + time.sleep(random.uniform(0.5, 1.0)) + val_list = self.client.get_trading_value_rank(market="J", limit=100) + for item in (val_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래대금순위 API → {len(val_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래대금순위 조회 실패: {e}") + + # 3) 회전율순위 100개 (키움 개미털기 2번째 소스와 동일) + try: + time.sleep(random.uniform(0.5, 1.0)) + turn_list = self.client.get_turnover_rank(market="J", limit=100) + for item in (turn_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 회전율순위 API → {len(turn_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 회전율순위 조회 실패: {e}") + + # 4) 등락률순위(상승) 100개 (기존 후보군 풀 확대) + try: + time.sleep(random.uniform(0.5, 1.0)) + chg_list = self.client.get_price_change_rank(market="J", sort_type="1", limit=100) + for item in (chg_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 등락률순위(상승) API → {len(chg_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 등락률순위(상승) 조회 실패: {e}") + + # 4-2) 등락률순위(하락) 100개 — 낙폭 큰 종목 직접 조회 → N자 망치봉 스캔 효율·Pass-낙폭 포착 + try: + time.sleep(random.uniform(0.5, 1.0)) + decline_list = self.client.get_price_decline_rank(market="J", limit=100) + for item in (decline_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 등락률순위(하락) API → {len(decline_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 등락률순위(하락) 조회 실패: {e}") + + # 5) 거래증가율순위 100개 (거래량/대금/회전율과 다른 풀 → 후보 다양화) + try: + time.sleep(random.uniform(0.5, 1.0)) + growth_list = self.client.get_volume_growth_rank(market="J", limit=100) + for item in (growth_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래증가율순위 API → {len(growth_list)}건 수신, 누적 {len(scan_list)}종목 (6소스 합산 → 개미털기 필터)") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래증가율순위 조회 실패: {e}") + + if not scan_list: + logger.warning(" ⚠️ [스캔유니버스] API에서 0건 수신 → 스캔 불가 (권한/계정/시간 확인)") + return [] + + scan_list = scan_list[:max_codes] + # 종목명 비어 있으면 시세 배치로 채우기 (KIS volume-rank는 종목명 미제공 시 많음) + need_name_codes = [x["code"] for x in scan_list[:20] if not (x.get("name") or "").strip()] + if need_name_codes: + try: + time.sleep(random.uniform(0.2, 0.4)) + batch = self.client.inquire_prices_batch(need_name_codes[:20]) + name_map = {} + _market_names = {"KOSPI", "KOSDAQ", "ETF", "KOSPI200", "KSQ150"} + for code, out in (batch or {}).items(): + n = (out.get("stck_kor_isnm") or out.get("rprs_mrkt_kor_name") or "").strip() + if n and n not in _market_names: + name_map[code] = n + for x in scan_list: + if not (x.get("name") or "").strip() and x["code"] in name_map: + x["name"] = name_map[x["code"]] + except Exception as e: + logger.debug(f" 스캔 종목명 배치 조회 스킵: {e}") + for x in scan_list: + if not (x.get("name") or "").strip(): + x["name"] = x["code"] + + logger.info( + f" 📋 스캔 대상: {len(scan_list)}개 종목 (거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율 각 100건 합산 → 개미털기 필터)" + ) + if scan_list: + part = ", ".join(f"{x['code']} {x.get('name') or x['code']}" for x in scan_list[:15]) + logger.info(f" 📋 스캔 대상(일부): {part}{' ...' if len(scan_list) > 15 else ''}") + return scan_list + + def scan_ant_shaking_candidates(self, max_candidates=20): + """개미털기(눌림목) 후보 종목 스캔 - KIS API로 스캔 대상 조회 후 필터링""" + logger.info("🐜 [개미털기] 고급 스캔 시작 (KIS API 스캔유니버스 사용)") + logger.info( + " 📌 스캔 대상 리스트는 DB에 저장되지 않음. " + "수치 체크(낙폭/회복률 등)는 이 리스트를 순회하며 개미털기 전략 필터를 적용한 결과입니다." + ) + candidates = [] + seen_codes = set() + # 필터별 탈락 건수 (0개 나오는 이유 확인용) - 세분화 + filter_counts = { + "낙폭부족": 0, + "회복률부족": 0, + "피뢰침(고점근접)": 0, + "피뢰침(급등주)": 0, + "RSI과열": 0, + "MA20": 0, + "API응답없음": 0, + "API예외": 0, + "시가0": 0, + "동전주": 0, + "가격파싱오류": 0, + } + + scan_list = self._fetch_scan_universe_from_api(max_codes=500) + scan_codes = [x["code"] for x in scan_list] + # scan_list를 딕셔너리로 변환하여 코드로 종목명을 빠르게 찾을 수 있게 함 + scan_name_map = {x["code"]: x.get("name", "") for x in scan_list} + if not scan_codes: + logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → 스캔 생략 (API에서 종목 리스트를 받지 못함)") + return [] + + # 스캔 대상 리스트를 거래량/낙폭 체크 전에 한 번 출력 (종목명 · 코드) + # 참고: 위 [스캔유니버스] 6소스(거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율) 합산 → 동일 개미털기 필터 적용 + logger.info(f" 📋 [개미털기 스캔 대상] {len(scan_list)}개 (6소스 합산, 종목명 · 코드)") + for i, x in enumerate(scan_list): + logger.info(f" {i+1}. {x.get('name') or x['code']} {x['code']}") + + # 체결강도 상위 API(FHPST01710000) 1회 조회 → 통과 종목 보너스(100 이상 +10, 120 이상 +20) 적용용 + execution_strength_map = {} + try: + 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점)") + except Exception as e: + logger.debug(f"체결강도 맵 로드 스킵: {e}") + + # 순차 조회 (한투 API는 다중 종목 조회 미지원 - 순차 조회 필수) + # 규칙: 일반 루프에는 random.sleep(1~3) 기본 적용 (서버 부하 방지) + total_scanned = 0 + passed_filters = 0 + + for code in scan_codes: + total_scanned += 1 + if code in seen_codes: + continue + seen_codes.add(code) + + try: + # 순차 조회 (종목당 1회 API 호출) + price_data = self.client.inquire_price(code) + if not price_data: + filter_counts["API응답없음"] += 1 + if code == "001510": # SK증권 추적 + logger.warning(f" ⚠️ SK증권(001510) API응답없음 (상세는 위 [현재가API] 로그 참고)") + time.sleep(random.uniform(1.0, 2.0)) + continue + time.sleep(random.uniform(1.0, 2.0)) + + # 가격 데이터 파싱 + try: + current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) + open_price = abs(float(str(price_data.get("stck_oprc", current_price)).replace(",", ""))) + high_price = abs(float(str(price_data.get("stck_hgpr", current_price)).replace(",", ""))) + low_price = abs(float(str(price_data.get("stck_lwpr", current_price)).replace(",", ""))) + volume = int(float(str(price_data.get("acml_vol", 0)).replace(",", ""))) + except (ValueError, TypeError) as e: + filter_counts["가격파싱오류"] += 1 + logger.debug(f"가격 파싱 실패({code}): {e}") + continue + + if open_price == 0: + filter_counts["시가0"] += 1 + continue + if current_price < 1000: # 동전주 제외 + filter_counts["동전주"] += 1 + continue + + # 종목명 가져오기: 시장명(KOSPI/KOSDAQ 등)이 아닌 진짜 종목명만 사용 + # KIS API는 rprs_mrkt_kor_name에 시장구분을 넣는 경우가 있어, stck_kor_isnm(종목한글명) 우선 사용 + _MARKET_NAMES = {"KOSPI", "KOSDAQ", "ETF", "KOSPI200", "KSQ150"} + name = scan_name_map.get(code, "").strip() + if not name or name in _MARKET_NAMES: + name = (price_data.get("stck_kor_isnm") or price_data.get("rprs_mrkt_kor_name") or "").strip() + if not name or name in _MARKET_NAMES: + name = code + + # 낙폭 계산 + if open_price > 0: + drop_rate = (open_price - low_price) / open_price + else: + drop_rate = 0 + total_range = high_price - low_price + if total_range > 0: + recovery_pos = (current_price - low_price) / total_range + else: + recovery_pos = 0 + + # 필터 조건 수치 로그 (디버깅용) + logger.debug( + f" 📊 [{name} {code}] 수치: " + f"낙폭 {drop_rate*100:.2f}% (기준: {self.min_drop_rate*100:.1f}%) | " + f"회복 {recovery_pos*100:.1f}% (기준: {self.min_recovery_ratio*100:.0f}%) | " + f"고점 {high_price:,.0f}원 | 저점 {low_price:,.0f}원 | 현재 {current_price:,.0f}원" + ) + + # [필터 1] 낙폭 체크 + if drop_rate < self.min_drop_rate: + filter_counts["낙폭부족"] += 1 + logger.info( + f"{LOG_YELLOW}🔍 [탈락-낙폭] {name} {code}: 낙폭 {drop_rate*100:.2f}% < {self.min_drop_rate*100:.1f}% " + f"(시가 {open_price:,.0f}원 → 저점 {low_price:,.0f}원){LOG_RESET}" + ) + if code == "001510": # SK증권 추적 + logger.warning(f" ⚠️ SK증권(001510) 낙폭부족으로 탈락: 낙폭={drop_rate*100:.2f}%, 기준={self.min_drop_rate*100:.1f}%") + continue + + # [필터 2] 회복률 체크 + if recovery_pos < self.min_recovery_ratio: + filter_counts["회복률부족"] += 1 + logger.info( + f"{LOG_YELLOW}🔍 [탈락-회복률] {name} {code}: 회복률 {recovery_pos*100:.1f}% < {self.min_recovery_ratio*100:.0f}% " + f"(저점 {low_price:,.0f}원 → 현재 {current_price:,.0f}원 / 범위 {total_range:,.0f}원){LOG_RESET}" + ) + if code == "001510": # SK증권 추적 + 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}") + 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) + investor_score = 0 + if investor_trend: + total_net = investor_trend.get("total_net_buy", 0) + if total_net > 10000: + investor_score = 20 # 강한 매수세 + elif total_net > 0: + investor_score = 10 # 매수세 + + # 조건 통과: 낙폭 3% 이상 & 회복 50% 이상 + if drop_rate >= self.min_drop_rate and recovery_pos >= self.min_recovery_ratio: + # ML 승률 예측 (USE_ML_SIGNAL=true일 때만) + ml_prob = None + if self.use_ml_signal and self.ml_predictor: + try: + # ML 피처 추출 (간단 버전 - 실제로는 더 많은 피처 필요) + # TODO: 실제 피처 데이터로 교체 필요 (RSI, 거래량비, 이동평균 등) + ml_features = { + "rsi": 50.0, # 임시값 + "volume_ratio": 1.0, + "tail_length_pct": drop_rate * 100, + "ma5_gap_pct": 0.0, + "ma20_gap_pct": 0.0, + "foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0, + "institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0, + "market_hour": dt.now().hour, + } + ml_prob = self.ml_predictor.predict_win_probability(ml_features) + + # ML 임계값 미달 시 스킵 + if ml_prob < self.ml_min_probability: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-ML] {name} {code}: ML 승률 {ml_prob:.1%} < {self.ml_min_probability:.1%}{LOG_RESET}" + ) + continue + 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 + if ml_prob is not None: + score += (ml_prob - 0.5) * 100 # ML 승률 가산점 + # 체결강도 상위 API(FHPST01710000) 보너스: 100 이상 +10점, 120 이상 +20점 + execution_strength = execution_strength_map.get(code, 0) + if execution_strength >= 120: + score += 20 + elif execution_strength >= 100: + score += 10 + candidate = { + "code": code, + "name": name, + "price": current_price, + "score": score, + "drop_rate": drop_rate, + "recovery": recovery_pos, + "volume": volume, + "investor_trend": investor_trend, + "execution_strength": execution_strength, + } + if ml_prob is not None: + candidate["ml_probability"] = ml_prob + + candidates.append(candidate) + passed_filters += 1 + + # 통과 즉시 DB 저장 (매매 루프가 스캔 완료를 기다리지 않고 실시간으로 후보 읽기 위함) + # 같은 종목 중복 시 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}") + + 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 "" + logger.info( + f"{LOG_GREEN}✅ [통과] {name} {code}: 낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 강도 {score:.1f}{strength_info}{ml_info}{LOG_RESET}" + ) + + except Exception as e: + filter_counts["API예외"] = filter_counts.get("API예외", 0) + 1 + logger.warning( + f"종목 스캔 예외 code={code} exception={e!r} type={type(e).__name__}" + ) + time.sleep(random.uniform(1.0, 2.0)) + continue + + candidates.sort(key=lambda x: x["score"], reverse=True) + + # 필터별 탈락/통과 요약 (색상: 탈락=노랑, 통과=초록) + 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}") + count_ge4 = sum(1 for c in candidates if c.get("score", 0) >= 4.0) + logger.info( + f" ✅ 스캔 완료: 개미털기 {len(candidates)}개 통과 (강도 4.0 이상: {count_ge4}개) " + f"[스캔 {total_scanned}개 → 필터 통과 {passed_filters}개]" + ) + # 통과 종목 전부 출력: 종목명 · 코드 · 강도 (몇 개 안 되므로 전부 표시) + if candidates: + logger.info(" 📌 [개미털기 통과 목록] 종목명 · 코드 · 강도") + for i, c in enumerate(candidates): + logger.info(f" {i+1}. {c['name']} {c['code']} 강도 {c['score']:.1f}") + # 강도순 상위 10개 → Mattermost 전송 + top10 = candidates[:10] + lines = [f"🐜 **개미털기 강도순 TOP{len(top10)}** (스캔 {total_scanned}개 중 통과 {len(candidates)}개)"] + for i, c in enumerate(top10, 1): + name = (c.get("name") or c.get("code") or "").strip() + score = c.get("score", 0) + lines.append(f"{i}. 강도 **{score:.1f}** {name}") + try: + self.send_mm("\n".join(lines)) + except Exception as e: + logger.debug(f"Mattermost 개미털기 TOP10 전송 스킵: {e}") + return candidates[:max_candidates] + + def calculate_atr(self, df, period=14): + """ + ATR (Average True Range) 계산 - 변동성 지표 + - TR(True Range) = max(고가-저가, |고가-전일종가|, |저가-전일종가|) + - ATR = TR의 14일 이동평균 + """ + try: + if df is None or len(df) < period: + return 0 + + df = df.copy() + # True Range 계산 + high_low = df['high'] - df['low'] + high_close = (df['high'] - df['close'].shift()).abs() + low_close = (df['low'] - df['close'].shift()).abs() + df['tr'] = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1) + + # ATR = TR의 14일 이동평균 + atr = df['tr'].rolling(window=period).mean().iloc[-1] + return float(atr) if not pd.isna(atr) and atr > 0 else 0 + except Exception as e: + logger.debug(f"ATR 계산 실패: {e}") + return 0 + + def check_sell_signals(self): + """ + 매도 신호 체크 (ATR 기반 변동성 매도 로직) + - 어깨 매도 (고점 대비 3% 하락) + - ATR 기반 스캘핑 (본절사수/익절보존) + - 순수익 보존 + - 목표가/손절가 + """ + if not self.holdings: + return [] + + sell_signals = [] + for code, holding in list(self.holdings.items()): + try: + name = holding.get("name", code) + buy_price = holding["buy_price"] + buy_time_str = holding.get("buy_time", "") + qty = holding["qty"] + + # 현재가 조회 + price_data = self.client.inquire_price(code) + if not price_data: + continue + + current_price = abs(float(price_data.get("stck_prpr", 0))) + if current_price == 0: + continue + + # 고점 갱신 (holdings에 저장) + max_price = holding.get("max_price", buy_price) + if current_price > max_price: + max_price = current_price + self.holdings[code]["max_price"] = max_price + + # 손익률 계산 + profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0 + profit_val = (current_price - buy_price) * qty + + # ATR 조회 (DB 또는 재계산) + atr = holding.get("atr_entry", 0) + if atr == 0: + # ATR 재계산 (3분봉) + try: + df = self.client.get_minute_chart(code, period="3", limit=20) + if not df.empty: + atr = self.calculate_atr(df) + if atr > 0: + self.holdings[code]["atr_entry"] = atr + except Exception as e: + logger.debug(f"ATR 조회 실패({code}): {e}") + atr = buy_price * 0.01 # 기본값 1% + + # 손절가/목표가 (ATR 기반 또는 기본값) + stop_price = holding.get("stop_price", buy_price * (1 + self.stop_loss_pct)) + target_price_atr = holding.get("target_price", buy_price * (1 + self.take_profit_pct)) + target_price_pct = buy_price * (1 + self.take_profit_pct) # 퍼센트 기반 목표가 + + # ATR 기반 손절가/목표가 계산 + if atr > 0: + stop_price = buy_price - (atr * self.stop_atr_multiplier) + target_price_atr = buy_price + (atr * self.target_atr_multiplier) + + # 목표가: ATR 기반과 퍼센트 기반 중 더 작은 값 사용 (둘 다 체크) + target_price = min(target_price_atr, target_price_pct) + + # 매도 사유 판단 + sell_reason = None + + # ========================================================== + # [1] 어깨 매도 (Shoulder Cut) - 최우선! + # 고점 대비 3% 이상 빠지면 수익/손실 불문하고 즉시 탈출 + # ========================================================== + shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", 0.03) + drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0 + if drop_from_high >= shoulder_cut_pct: + sell_reason = "어깨매도" + + # ========================================================== + # [2] 금액 기준 손절 (원 단위) + # ========================================================== + max_loss_per_trade_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000) + if not sell_reason and profit_val <= -max_loss_per_trade_krw: + sell_reason = "금액손실컷" + + # ========================================================== + # [3] ATR 기반 스캘핑 로직 + # ========================================================== + if not sell_reason and atr > 0: + # [스캘핑 1] 본절사수: 고점이 ATR 1배 이상 올랐는데 현재가가 ATR 0.2배 이하로 떨어짐 + if (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2): + sell_reason = "스캘핑_본절사수" + + # [스캘핑 2] 익절보존: 수익 중인데 고점 대비 ATR 1배 이상 하락 + if not sell_reason and current_price < (max_price - atr * 1.0) and profit_pct > 0: + sell_reason = "스캘핑_익절보존" + + # 보유 시간 계산 (N자 패턴 등 다음날 상승 가능성 있는 종목 24시간 보유) + hours_passed = 0 + if buy_time_str: + try: + buy_time = dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S") + hours_passed = (dt.now() - buy_time).total_seconds() / 3600 + except: + pass + + # ========================================================== + # [4] 빠른 익절 보호 (매수 후 30분 이내) + # ========================================================== + if not sell_reason and hours_passed > 0: + use_quick_profit = get_env_bool("USE_QUICK_PROFIT_PROTECTION", True) + if use_quick_profit and hours_passed < 0.5: + if max_price >= buy_price * 1.005 and current_price <= buy_price * 1.0015: + sell_reason = "💨 작은수익보호" + + # ========================================================== + # [5] 24시간 보유 전략 (N자 패턴 등 다음날 상승 가능성) + # ========================================================== + # 세력이 N자 만들어서 털어먹으려는 종목은 하루에 안 끝나고 다음날 오르는 경우가 있음 + # 24시간 이내: 특정 조건에서만 매도 (보수적) + # 24시간 이후: 일반 매도 조건 적용 + min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) # 최소 보유 시간 (기본 24시간) + + if not sell_reason and hours_passed > 0: + if hours_passed < min_hold_hours: + # 24시간 이내: 큰 수익(5% 이상) 또는 고점 대비 큰 하락만 매도 + if profit_pct > 0.05: # 5% 이상 수익 + sell_reason = f"💰 {hours_passed:.1f}시간내 5%+ 익절" + elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97: + # 고점 7% 이상 찍고 고점 대비 3% 이상 하락 + sell_reason = f"📈 {hours_passed:.1f}시간내 고점7%→3%하락" + # 그 외에는 24시간 보유 유지 (손절은 제외) + else: + # 24시간 이후: 일반 매도 조건 적용 + if profit_pct > 0.02: # 2% 이상 수익 + sell_reason = f"⏰ {hours_passed:.1f}시간 경과 2%+ 익절" + elif profit_pct > 0 and current_price < max_price * 0.97: + # 수익 중인데 고점 대비 3% 이상 하락 + sell_reason = f"⏰ {hours_passed:.1f}시간 경과 익절보호" + + # ========================================================== + # [6] 목표가 달성 및 손절 (24시간 보유 전략과 별개로 항상 체크) + # ========================================================== + if not sell_reason: + # 목표가 달성 체크 (ATR 기반 또는 퍼센트 기반 중 먼저 도달한 것) + if current_price >= target_price: + if current_price >= target_price_atr and current_price >= target_price_pct: + sell_reason = "목표달성(ATR+퍼센트)" + elif current_price >= target_price_atr: + sell_reason = "목표달성(ATR)" + else: + sell_reason = "목표달성(퍼센트)" + elif current_price <= stop_price: + sell_reason = "전략손절" + elif profit_pct <= self.stop_loss_pct: + sell_reason = f"칼손절({profit_pct * 100:.1f}%)" + elif profit_pct >= self.take_profit_pct: + sell_reason = "익절(퍼센트)" + + # 매도 신호 추가 + if sell_reason: + sell_signals.append({ + "code": code, + "name": name, + "reason": sell_reason, + "profit_pct": profit_pct, + "qty": qty, + "price": current_price, + }) + + time.sleep(random.uniform(0.3, 0.7)) + except Exception as e: + logger.error(f"매도 신호 체크 실패({code}): {e}") + continue + + return sell_signals + + def execute_buy(self, signal): + """ + 매수 실행 + - 키움 봇과 동일하게 대형주/소형주 비율 맞추는 로직 포함 + - 대형주: 기본 금액 100% / 중형주: 85% / 소형주: 70% + - 켈리 기반 매수 금액 + 종목당 최대 15% 제한 + """ + code = signal["code"] + name = signal["name"] + price = signal["price"] + + # 이미 보유 중이면 스킵 + if code in self.holdings: + logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵") + return False + + # 최대 보유 종목 수 체크 + if len(self.holdings) >= self.max_stocks: + logger.warning(f"⚠️ 최대 보유 종목 수 도달 ({self.max_stocks}개)") + return False + + # 🔥 매수 직전 예수금 실시간 확인 + if not self._update_account_light(profit_val=0): + logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵") + return False + + # ============================================================ + # [대/중/소형주 구분] - 일봉 거래대금 평균 (키움 봇과 동일) + # ============================================================ + size_class = None + try: + df = self.client.get_daily_chart(code, limit=10) + if not df.empty and "volume" in df.columns and "close" in df.columns: + trade_values = df["volume"] * df["close"] + avg_trade_value = trade_values.mean() + large_min = get_env_float("SIZE_CLASS_LARGE_MIN", 5000000000) # 50억 (대형주) + mid_min = get_env_float("SIZE_CLASS_MID_MIN", 500000000) # 5억 (중형주) + if avg_trade_value >= large_min: + size_class = "대" + elif avg_trade_value >= mid_min: + size_class = "중" + else: + size_class = "소" + logger.info( + f"📊 [{name}] 거래대금 평균 {avg_trade_value/1e8:.1f}억원 → {size_class}형주" + ) + except Exception as e: + logger.debug(f"대/중/소형 조회 스킵({code}): {e}") + + # ============================================================ + # [매수 금액] 변동성 역가중 (Volatility Inverse Weighting) + # ============================================================ + # ATR 계산용 분봉 데이터 (변동성 계산에 필요) + df_minute = None + try: + df_minute = self.client.get_minute_chart(code, period="3", limit=20) + except Exception as e: + logger.debug(f"분봉 조회 실패({code}): {e}") + + # RiskManager 사용 시: 변동성 역가중으로 매수 금액 계산 + if self.risk_mgr is not None: + # 켈리 비율 (DB에서 계산, 없으면 None) + kelly_fraction = None + if self.risk_mgr.use_kelly: + try: + kelly_fraction = self.db.calculate_half_kelly() + except Exception as e: + logger.debug(f"켈리 비율 계산 스킵: {e}") + + # 변동성 역가중 매수 금액 계산 + amount = self.risk_mgr.get_position_size( + stock_name=name, + current_balance=self.current_cash, + df=df_minute, # ATR 계산용 분봉 데이터 + kelly_fraction=kelly_fraction, + size_class=size_class, # 대/중/소형 구분 + ) + + if amount <= 0: + logger.warning(f"⚠️ [{name}] RiskManager 계산 금액 0원 -> 매수 스킵") + return False + + # 수량 계산 (수수료 고려) + qty = self.risk_mgr.calculate_quantity(price, amount) + else: + # 폴백: 기존 고정 슬롯 방식 (RiskManager 미사용 시) + if self.max_stocks > 0: + slot_money = int(self.current_cash * 0.9 / self.max_stocks) + else: + slot_money = 100000 + base_amount = min(slot_money, 100000) + if self.stop_loss_pct != 0: + stop_pct_abs = abs(self.stop_loss_pct) + else: + stop_pct_abs = 0.04 + if stop_pct_abs > 0: + kelly_risk_amount = self.current_cash * self.risk_pct_per_trade * self.kelly_multiplier + kelly_based_amount = int(kelly_risk_amount / stop_pct_abs) + base_amount = min(base_amount, kelly_based_amount) + if size_class == "소": + amount = int(base_amount * 0.7) + logger.info(f"💰 [{name}] 소형주 → 매수 금액 70%: {amount:,.0f}원") + elif size_class == "중": + amount = int(base_amount * 0.85) + logger.info(f"💰 [{name}] 중형주 → 매수 금액 85%: {amount:,.0f}원") + else: + amount = base_amount + max_limit = int(self.current_cash * self.max_position_pct) + if amount > max_limit: + logger.info(f"📐 [{name}] 최대 포지션 제한: {amount:,.0f}원 → {max_limit:,.0f}원") + amount = max_limit + amount = max(amount, self.min_position_amount) + qty = int(amount / price) + if qty <= 0: + logger.warning(f"⚠️ [{name}] 매수 수량 0 (가격: {price:,.0f}원, 금액: {amount:,.0f}원)") + return False + + required_amount = price * qty * 1.05 + if self.current_cash < required_amount: + logger.warning( + f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / " + f"보유 {self.current_cash:,.0f}원 -> 매수 스킵" + ) + return False + + # ATR 계산 (변동성 기반 손절가/목표가 설정용) + # df_minute는 위에서 이미 조회했으므로 재사용 + atr = 0 + stop_price = price * (1 + self.stop_loss_pct) + target_price = price * (1 + self.take_profit_pct) + if df_minute is not None and not df_minute.empty: + try: + atr = self.calculate_atr(df_minute) + if atr > 0: + # ATR 기반 손절가/목표가 설정 + stop_price = price - (atr * self.stop_atr_multiplier) + target_price = price + (atr * self.target_atr_multiplier) + logger.info(f"📊 [{name}] ATR 기반 손절가/목표가: ATR={atr:.0f}원, 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + except Exception as e: + logger.debug(f"ATR 계산 스킵({code}): {e}") + atr = price * 0.01 # 기본값 1% + else: + atr = price * 0.01 # 기본값 1% + + success = self.client.buy_market_order(code, qty) + if success: + self._update_account_light(profit_val=0) + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + self.holdings[code] = { + "buy_price": price, + "qty": qty, + "buy_time": buy_time, + "name": name, + "max_price": price, # 고점 추적 + "atr_entry": atr, # 매수 시점 ATR 저장 + "stop_price": stop_price, # 손절가 + "target_price": target_price, # 목표가 + } + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SHORT_ANT_SHAKING", + "avg_buy_price": price, + "current_price": price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": buy_time, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": atr, + }) + logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + return True + return False + + def execute_sell(self, signal): + """매도 실행""" + code = signal["code"] + name = signal["name"] + qty = signal["qty"] + + if code not in self.holdings: + logger.warning(f"⚠️ [{name}] 보유 종목 아님") + return False + + # 매도 주문 + success = self.client.sell_market_order(code, qty) + if success: + # 현재가 조회 + price_data = self.client.inquire_price(code) + if price_data: + sell_price = abs(float(price_data.get("stck_prpr", 0))) + else: + sell_price = signal.get("price", 0) + + # 손익 계산 (매도 후 총자산 반영용) + holding = self.holdings.get(code, {}) + buy_price = holding.get("buy_price", sell_price) + profit_val = (sell_price - buy_price) * qty # 손익 금액 + + # DB에서 매도 처리 + self.db.close_trade( + code=code, + sell_price=sell_price, + sell_reason=signal['reason'], + ) + + del self.holdings[code] + + # 🔥 매도 후 예수금 + 총자산 즉시 업데이트 (손익 반영) + self._update_account_light(profit_val=profit_val) + + logger.info(f"💸 [매도 체결] {name} ({code}): {qty}주 ({signal['reason']}, {signal['profit_pct']*100:+.2f}%)") + return True + + return False + + def run(self): + """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """비동기 메인 루프 - 백그라운드 태스크 시작 후 동기 매매 루프 실행""" + logger.info("🚀 단타 트레이딩 봇 시작 (비동기 백그라운드 작업 활성화)") + + # 백그라운드 태스크 시작 + self._universe_task = asyncio.create_task(self._universe_scan_scheduler()) + self._report_task = asyncio.create_task(self._report_scheduler()) + self._asset_task = asyncio.create_task(self._asset_update_scheduler()) + logger.info("✅ 백그라운드 태스크 시작 완료 (유니버스 스캔, 리포트, 자산 업데이트)") + + # 동기 매매 루프는 별도 스레드에서 실행 (메인 이벤트 루프 블로킹 방지) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._sync_trading_loop) + + def _sync_trading_loop(self): + """동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리""" + logger.info("📈 매매 루프 시작 (동기 모드)") + + while True: + try: + now = dt.now() + current_date = now.strftime("%Y-%m-%d") + + # 날짜 변경 시 리포트 플래그 리셋 + if current_date != self.today_date: + self.today_date = current_date + 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 + logger.info(f"📅 날짜 변경: {current_date}") + + # 리포트 전송은 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.hour == 13 and now.minute == 0: + # self.send_morning_report() + # self.send_ai_report() + # elif now.hour == 15 and now.minute == 15: + # self.send_closing_report() + # elif now.hour == 15 and now.minute == 35: + # self.send_final_report() + + # 장 상태 체크 + if not self.check_market_status(): + logger.info("⏸ 장 시간 아님 - 대기 중...") + time.sleep(60) + continue + + # 자산 정보 업데이트는 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.minute % 30 == 0: # 30분마다 + # self._update_assets() + + # 매도 신호 체크 (우선) - 메인 루프에서 처리 + sell_signals = self.check_sell_signals() + for signal in sell_signals: + self.execute_sell(signal) + db_candidates = self.db.get_target_candidates() + if db_candidates: + logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(db_candidates)}개 | 보유:{active_count}/{self.max_stocks}") + # DB 후보군이 있으면 사용 + for db_item in db_candidates[:1]: # 상위 1개만 + code = db_item['code'] + name = db_item['name'] + # 실제 가격 확인 후 매수 + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + candidate = { + 'code': code, + 'name': name, + 'price': current_price, + 'score': db_item.get('score', 0), + } + self.execute_buy(candidate) + time.sleep(random.uniform(1, 2)) + break + else: + # DB 후보군이 없으면 대기 (유니버스 업데이트 대기) + # ⚠️ 직접 스캔하지 않음 - 백그라운드 태스크에서 5분마다 업데이트됨 + if active_count == 0: # 첫 실행 시에만 로그 + logger.info(f"🔍 [매수 기회 탐색] 타겟:0개 (유니버스 스캔 대기 중) | 보유:{active_count}/{self.max_stocks}") + + # 대기 + time.sleep(random.uniform(3, 5)) + + except KeyboardInterrupt: + logger.info("⏹ 봇 종료") + # 백그라운드 태스크 취소 + if self._universe_task: + self._universe_task.cancel() + if self._report_task: + self._report_task.cancel() + if self._asset_task: + self._asset_task.cancel() + break + except Exception as e: + logger.error(f"❌ 루프 에러: {e}") + time.sleep(5) + + +if __name__ == "__main__": + bot = ShortTradingBot() + bot.run() diff --git a/ml_predictor.py b/ml_predictor.py new file mode 100644 index 0000000..4ff41fa --- /dev/null +++ b/ml_predictor.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +KIS Bot용 ML 승률 예측 모델 +- kis_bot/quant_bot.db의 trade_history 데이터로 학습 +- 매수 신호의 승률 예측 (0.0 ~ 1.0) +- 주간 단위 자동 재학습 +""" +import os +import pickle +import sqlite3 +import logging +from pathlib import Path +from datetime import datetime, timedelta + +import numpy as np +import pandas as pd + +# Logger 설정 +logger = logging.getLogger("KIS_MLPredictor") + +try: + from sklearn.ensemble import RandomForestClassifier + from sklearn.model_selection import train_test_split + from sklearn.metrics import accuracy_score, precision_score, recall_score + ML_AVAILABLE = True +except ImportError: + ML_AVAILABLE = False + logger.warning("⚠️ scikit-learn 미설치! ML 기능 사용 불가") + logger.warning(" 설치: pip install scikit-learn") + + +SCRIPT_DIR = Path(__file__).resolve().parent + + +class MLPredictor: + """매수 신호 승률 예측 모델""" + + def __init__( + self, + db_path: str = None, + model_path: str = None, + ): + # 기본값: kis_bot/quant_bot.db, kis_bot/ml_model.pkl + self.db_path = db_path or str(SCRIPT_DIR / "quant_bot.db") + self.model_path = model_path or str(SCRIPT_DIR / "ml_model.pkl") + self.model = None + self.feature_names = [ + "rsi", + "volume_ratio", + "tail_length_pct", + "ma5_gap_pct", + "ma20_gap_pct", + "foreign_net_buy", + "institution_net_buy", + "market_hour", + ] + self.min_train_samples = 30 + + if not ML_AVAILABLE: + logger.error("❌ scikit-learn이 설치되지 않았습니다!") + return + + self.load_model() + + def extract_features_from_db(self, days: int = 90) -> pd.DataFrame: + """DB에서 학습용 피처 추출 + + 현재는 trade_history의 profit_rate 기반으로 승/패 라벨만 생성하고, + 피처는 프로토타입 단계로 랜덤 값을 사용한다. + (실전에서는 active_trades에 진입 시점 피처를 저장해서 사용해야 함) + """ + try: + conn = sqlite3.connect(self.db_path) + cutoff_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + query = f""" + SELECT profit_rate, buy_date, sell_date, strategy + FROM trade_history + WHERE sell_date >= '{cutoff_date}' + ORDER BY sell_date DESC + """ + + df = pd.read_sql_query(query, conn) + conn.close() + + if len(df) < self.min_train_samples: + logger.warning( + f"⚠️ 학습 데이터 부족: {len(df)}건 (최소 {self.min_train_samples}건 필요)" + ) + return None + + df["is_win"] = (df["profit_rate"] > 0).astype(int) + logger.info( + f"📊 학습 데이터 로드: {len(df)}건 " + f"(익절: {df['is_win'].sum()}건, 손절: {(1 - df['is_win']).sum()}건)" + ) + + return df + + except Exception as e: + logger.error(f"❌ 피처 추출 실패: {e}") + return None + + def train_model(self, retrain: bool = False) -> bool: + """모델 학습""" + if not ML_AVAILABLE: + logger.error("❌ scikit-learn 미설치로 학습 불가") + return False + + if self.model is not None and not retrain: + logger.info("✅ 기존 모델 사용") + return True + + df = self.extract_features_from_db(days=90) + + if df is None or len(df) < self.min_train_samples: + logger.warning("⚠️ 학습 데이터 부족 - ML 모델 사용 불가") + return False + + logger.warning("⚠️ [프로토타입] 랜덤 피처로 학습 중") + logger.warning(" → 실제 운영 시: active_trades 테이블에 진입 피처 저장 후 사용") + + # TODO: 실제 피처 데이터로 교체 필요 + # 현재는 데모용 랜덤 피처 사용 + np.random.seed(42) + X = pd.DataFrame( + { + "rsi": np.random.uniform(20, 80, len(df)), + "volume_ratio": np.random.uniform(0.5, 5.0, len(df)), + "tail_length_pct": np.random.uniform(0, 5, len(df)), + "ma5_gap_pct": np.random.uniform(-5, 5, len(df)), + "ma20_gap_pct": np.random.uniform(-10, 10, len(df)), + "foreign_net_buy": np.random.uniform(-1000, 1000, len(df)), + "institution_net_buy": np.random.uniform(-500, 500, len(df)), + "market_hour": np.random.randint(9, 15, len(df)), + } + ) + y = df["is_win"].values + + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.2, random_state=42, stratify=y + ) + + logger.info("🤖 RandomForest 학습 시작...") + self.model = RandomForestClassifier( + n_estimators=100, + max_depth=10, + min_samples_split=5, + random_state=42, + ) + self.model.fit(X_train, y_train) + + y_pred = self.model.predict(X_test) + accuracy = accuracy_score(y_test, y_pred) + precision = precision_score(y_test, y_pred, zero_division=0) + recall = recall_score(y_test, y_pred, zero_division=0) + + logger.info("✅ 학습 완료!") + logger.info(f" 정확도: {accuracy:.2%}") + logger.info(f" 정밀도: {precision:.2%}") + logger.info(f" 재현율: {recall:.2%}") + + feature_importance = sorted( + zip(self.feature_names, self.model.feature_importances_), + key=lambda x: x[1], + reverse=True, + ) + logger.info(" 중요 피처:") + for fname, importance in feature_importance[:5]: + logger.info(f" {fname}: {importance:.3f}") + + self.save_model() + return True + + def predict_win_probability(self, features: dict) -> float: + """매수 신호의 승률 예측 (0.0 ~ 1.0)""" + if not ML_AVAILABLE or self.model is None: + return 0.5 + + try: + X = pd.DataFrame([features])[self.feature_names] + proba = self.model.predict_proba(X)[0] + win_prob = proba[1] + return float(win_prob) + except Exception as e: + logger.error(f"❌ 예측 실패: {e}") + return 0.5 + + def save_model(self) -> None: + """모델 파일로 저장""" + try: + with open(self.model_path, "wb") as f: + pickle.dump(self.model, f) + logger.info(f"💾 모델 저장: {self.model_path}") + except Exception as e: + logger.error(f"❌ 모델 저장 실패: {e}") + + def load_model(self) -> bool: + """저장된 모델 로드""" + if not ML_AVAILABLE: + return False + + if os.path.exists(self.model_path): + try: + with open(self.model_path, "rb") as f: + self.model = pickle.load(f) + logger.info(f"✅ 모델 로드: {self.model_path}") + return True + except Exception as e: + logger.error(f"❌ 모델 로드 실패: {e}") + else: + logger.info("ℹ️ 저장된 모델 없음 - 첫 실행 시 학습 필요") + + return False + + def should_retrain(self) -> bool: + """재학습이 필요한지 체크 (7일 경과 시)""" + if not os.path.exists(self.model_path): + return True + + model_mtime = datetime.fromtimestamp(os.path.getmtime(self.model_path)) + days_old = (datetime.now() - model_mtime).days + + if days_old >= 7: + logger.info(f"🔄 모델 {days_old}일 경과 → 재학습 필요") + return True + + return False + diff --git a/modify_db_schema.py b/modify_db_schema.py new file mode 100644 index 0000000..8b615f7 --- /dev/null +++ b/modify_db_schema.py @@ -0,0 +1,37 @@ +import sqlite3 +import os + +# 데이터베이스 파일 경로 +db_path = os.path.join(os.path.dirname(__file__), 'quant_bot.db') + +# 연결 생성 +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 기존 필드 이름 변경 +print("기존 테이블 필드 변경 중...") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_APP_KEY TO KIS_APP_KEY_REAL") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_APP_SECRET TO KIS_APP_SECRET_REAL") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_ACCOUNT_NO TO KIS_ACCOUNT_NO_REAL") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_ACCOUNT_CODE TO KIS_ACCOUNT_CODE_REAL") + +# 모의계좌용 필드 추가 +print("모의계좌용 필드 추가 중...") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_APP_KEY_MOCK TEXT") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_APP_SECRET_MOCK TEXT") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_ACCOUNT_NO_MOCK TEXT") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_ACCOUNT_CODE_MOCK TEXT") + +# 변경된 필드명으로 데이터 이동 (기존 데이터 복사) +print("기존 데이터 이동 중...") +# 실제 데이터베이스에서 KIS_APP_KEY_REAL을 KIS_APP_KEY_MOCK로 복사 (모의계좌용으로 사용) +cursor.execute("UPDATE env_config SET KIS_APP_KEY_MOCK = KIS_APP_KEY_REAL") +cursor.execute("UPDATE env_config SET KIS_APP_SECRET_MOCK = KIS_APP_SECRET_REAL") +cursor.execute("UPDATE env_config SET KIS_ACCOUNT_NO_MOCK = KIS_ACCOUNT_NO_REAL") +cursor.execute("UPDATE env_config SET KIS_ACCOUNT_CODE_MOCK = KIS_ACCOUNT_CODE_REAL") + +# 변경 내용 저장 +conn.commit() + +print("데이터베이스 스키마 변경 완료") +conn.close() \ No newline at end of file diff --git a/quant_bot.db b/quant_bot.db new file mode 100644 index 0000000000000000000000000000000000000000..a7d8ab617369cb02383ca48863efe8aab92232a7 GIT binary patch literal 77824 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 float: + """ + ATR(Average True Range) 기반 변동성 계산 + + Args: + df: OHLC 데이터프레임 (컬럼: open, high, low, close) + period: ATR 계산 기간 (기본 14) + + Returns: + 현재가 대비 ATR 비율 (%) + """ + try: + if df is None or len(df) < period: + logger.warning(f"⚠️ ATR 계산: 데이터 부족 -> 기본값 3.0% 리턴") + return 3.0 + + # True Range 계산 + 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 = TR의 이동평균 + atr = df['tr'].rolling(window=period).mean().iloc[-1] + current_price = df['close'].iloc[-1] + + if current_price == 0: + return 3.0 + + # 현재가 대비 ATR 비율 (%) + volatility_pct = (atr / current_price) * 100 + + # 최소값 보정 (0.5% 미만은 0.5%로) + return max(round(volatility_pct, 2), 0.5) + + except Exception as e: + logger.error(f"❌ ATR 계산 에러: {e}") + return 3.0 + + def calculate_volatility_simple(self, candles: List[Dict], period: int = 10) -> float: + """ + 간단한 변동성 계산 (고저차 비율 평균) + + Args: + candles: 캔들 데이터 리스트 [{'high': , 'low': , 'close': }, ...] + period: 계산 기간 (기본 10일) + + Returns: + 변동성 (%) + """ + try: + if not candles or len(candles) < period: + return 3.0 + + recent = candles[-period:] + volatility_sum = 0.0 + + for candle in recent: + high = float(candle.get('high', 0)) + low = float(candle.get('low', 0)) + close = float(candle.get('close', 1)) + + if close == 0: + continue + + # (고가 - 저가) / 종가 + volatility_sum += (high - low) / close + + avg_vol = (volatility_sum / len(recent)) * 100 + return max(round(avg_vol, 2), 0.5) + + except Exception as e: + logger.error(f"❌ 변동성 계산 에러: {e}") + return 3.0 + + def get_position_size( + self, + stock_name: str, + current_balance: float, + volatility_pct: float = None, + df: pd.DataFrame = None, + kelly_fraction: float = None, + size_class: str = None, + ) -> int: + """ + [메인 함수] 종목별 안전 매수 금액 계산 + + Args: + stock_name: 종목명 (로깅용) + current_balance: 현재 예수금 + volatility_pct: 변동성 (직접 제공 시) + df: OHLC 데이터프레임 (ATR 계산용) + kelly_fraction: 켈리 비율 (DB에서 계산한 값) + size_class: 대/중/소형 구분 ("대", "중", "소") + + Returns: + 추천 매수 금액 (원) + """ + try: + if current_balance <= 0: + logger.warning(f"⚠️ [{stock_name}] 예수금 0원 이하") + return 0 + + # 1. 변동성 계산 + if volatility_pct is None: + if df is not None and not df.empty: + volatility_pct = self.calculate_volatility_atr(df) + else: + logger.warning(f"⚠️ [{stock_name}] 변동성 데이터 없음 -> 기본값 3.0%") + volatility_pct = 3.0 + + # 2. 기본 리스크 금액 계산 (예수금 * 리스크 비율) + # 예: 1,000만 원 * 2% = 20만 원 (이 종목에서 최대 20만원까지 잃을 수 있음) + allowable_risk = current_balance * self.risk_pct + + # 3. 켈리 공식 적용 (쿼터 켈리/하프 켈리) + if self.use_kelly: + if kelly_fraction is not None and kelly_fraction > 0: + # DB에서 계산한 켈리 비율 사용 (하프 켈리) + base_position_pct = kelly_fraction * self.kelly_mult + allowable_risk = current_balance * base_position_pct + logger.info(f"🎲 [{stock_name}] 켈리 적용(DB): {base_position_pct*100:.1f}% (켈리={kelly_fraction*100:.1f}% × 배율={self.kelly_mult})") + else: + # 켈리 비율이 없으면 쿼터 켈리 배율을 기본값으로 사용 + # 예: 리스크 1% × 쿼터 켈리 0.25 = 0.25% (더 보수적) + base_position_pct = self.risk_pct * self.kelly_mult + allowable_risk = current_balance * base_position_pct + logger.info(f"🎲 [{stock_name}] 켈리 적용(기본): {base_position_pct*100:.1f}% (리스크={self.risk_pct*100}% × 쿼터켈리={self.kelly_mult})") + + # 4. 변동성 역가중으로 매수 금액 계산 + # 공식: (허용 손실액) / (변동성 비율) + # 변동성 5% -> 20만 / 0.05 = 400만원 매수 + # 변동성 10% -> 20만 / 0.10 = 200만원 매수 + risk_ratio = volatility_pct / 100.0 + calculated_amount = allowable_risk / risk_ratio + + # 5. 상한선 체크 (종목당 최대 비중) + max_limit = current_balance * self.max_pos_pct + + if calculated_amount > max_limit: + final_amount = max_limit + note = f"(최대{self.max_pos_pct*100:.0f}% 제한)" + else: + final_amount = calculated_amount + note = "(변동성기반)" + + # 5-2. 대형/소형 구간별 조정 (소형주는 포지션 축소) + if size_class == "소": + final_amount = int(final_amount * 0.7) + note = "(소형주 70%)" + elif size_class == "중": + final_amount = int(final_amount * 0.85) + note = "(중형주 85%)" + elif size_class == "대": + pass # 대형주는 기존과 동일 + + # 6. 하한선 체크 (너무 작은 금액은 거래 안함) + if final_amount < self.min_amount: + logger.info( + f"🚫 [{stock_name}] 계산 금액({final_amount:,.0f}원) < " + f"최소금액({self.min_amount:,.0f}원) -> 매수 보류" + ) + return 0 + + logger.info( + f"💰 [{stock_name}] 예수금:{current_balance:,.0f}원 | " + f"변동성:{volatility_pct:.2f}% | 추천:{int(final_amount):,.0f}원 {note}" + ) + + return int(final_amount) + + except Exception as e: + logger.error(f"❌ [{stock_name}] 포지션 사이즈 계산 실패: {e}") + return 0 + + def calculate_quantity( + self, + current_price: float, + target_amount: int, + fee_rate: float = 0.00015 + ) -> int: + """ + 목표 금액을 현재가와 수수료 고려하여 수량으로 변환 + + Args: + current_price: 현재 주가 + target_amount: 목표 투자 금액 + fee_rate: 수수료율 (기본 0.015%) + + Returns: + 매수 가능 수량 (주) + """ + try: + if current_price <= 0 or target_amount <= 0: + return 0 + + # 수수료 고려 실제 매수 가능 금액 + # 총비용 = 가격 * 수량 * (1 + 수수료율) + # 수량 = 목표금액 / (가격 * (1 + 수수료율)) + max_buy_amount = target_amount / (1 + fee_rate) + quantity = int(max_buy_amount / current_price) + + if quantity < 1: + return 0 + + # 예상 총 비용 계산 (확인용) + expected_cost = quantity * current_price * (1 + fee_rate) + + logger.debug( + f"💵 수량 계산: {current_price:,.0f}원 × {quantity}주 = " + f"예상비용 {expected_cost:,.0f}원 (목표: {target_amount:,.0f}원)" + ) + + return quantity + + except Exception as e: + logger.error(f"❌ 수량 계산 에러: {e}") + return 0 diff --git a/scripts/copy_env_row_to_latest.py b/scripts/copy_env_row_to_latest.py new file mode 100644 index 0000000..e69de29 diff --git a/update_env.py b/update_env.py new file mode 100644 index 0000000..75d461c --- /dev/null +++ b/update_env.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +환경변수 DB 업데이트 스크립트 +- 기존 최신 설정을 불러와서 +- 새로운 값으로 업데이트하고 +- 새 스냅샷으로 저장 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def update_env_config(): + """환경변수 업데이트 (대화형)""" + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + # 기존 최신 설정 불러오기 + latest = db.get_latest_env() + if latest and latest.get("snapshot"): + current = latest["snapshot"] + print("📋 현재 설정:") + print(f" (최신 스냅샷 ID: {latest['id']}, 생성일: {latest['created_at']})") + print() + else: + current = {} + print("⚠️ 기존 설정 없음 - 새로 생성합니다.") + print() + + # 업데이트할 값들 + updates = {} + + print("=" * 60) + print("환경변수 업데이트") + print("=" * 60) + print("(값을 입력하지 않으면 기존 값 유지, 'skip' 입력 시 건너뜀)") + print() + + # 한투 API 설정 + print("🔵 [한투 API 설정]") + val = input(f"KIS_APP_KEY [{current.get('KIS_APP_KEY', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_APP_KEY"] = val + + val = input(f"KIS_APP_SECRET [{current.get('KIS_APP_SECRET', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_APP_SECRET"] = val + + val = input(f"KIS_ACCOUNT_NO [{current.get('KIS_ACCOUNT_NO', '')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_ACCOUNT_NO"] = val + + val = input(f"KIS_ACCOUNT_CODE [{current.get('KIS_ACCOUNT_CODE', '01')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_ACCOUNT_CODE"] = val + + val = input(f"KIS_MOCK (true/false) [{current.get('KIS_MOCK', 'true')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_MOCK"] = val.lower() + + print() + + # Mattermost 설정 + print("💬 [Mattermost 설정]") + val = input(f"MM_SERVER_URL [{current.get('MM_SERVER_URL', 'https://mattermost.hoonfam.org')}]: ").strip() + if val and val.lower() != 'skip': + updates["MM_SERVER_URL"] = val + + val = input(f"MM_BOT_TOKEN_ [{current.get('MM_BOT_TOKEN_', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["MM_BOT_TOKEN_"] = val + + val = input(f"MATTERMOST_CHANNEL (기본 채널 alias) [{current.get('MATTERMOST_CHANNEL', 'stock')}]: ").strip() + if val and val.lower() != 'skip': + updates["MATTERMOST_CHANNEL"] = val + + val = input(f"KIS_SHORT_MM_CHANNEL (단타 봇 채널 alias) [{current.get('KIS_SHORT_MM_CHANNEL', '')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_SHORT_MM_CHANNEL"] = val + + val = input(f"KIS_LONG_MM_CHANNEL (롱타 봇 채널 alias) [{current.get('KIS_LONG_MM_CHANNEL', '')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_LONG_MM_CHANNEL"] = val + + print() + + # Gemini API 설정 + print("🤖 [Gemini AI 설정]") + val = input(f"GEMINI_API_KEY [{current.get('GEMINI_API_KEY', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["GEMINI_API_KEY"] = val + + print() + + # 기존 값과 병합 + new_snapshot = {**current, **updates} + + # 확인 + print("=" * 60) + print("업데이트할 값:") + print("=" * 60) + for key, value in updates.items(): + display_value = value[:30] + "..." if len(str(value)) > 30 else value + print(f" {key}: {display_value}") + + print() + confirm = input("위 설정을 저장하시겠습니까? (y/n): ").strip().lower() + + if confirm != 'y': + print("❌ 취소되었습니다.") + db.close() + return + + # 새 스냅샷 저장 + env_id = db.insert_env_snapshot(new_snapshot) + if env_id: + print(f"✅ 환경변수 저장 완료! (새 스냅샷 ID: {env_id})") + print() + print("💡 팁:") + print(" - 봇을 재시작하면 새 설정이 적용됩니다.") + print(" - mm_config.json 파일도 확인하세요:") + print(" {") + print(' "channels": {') + print(' "stock": "채널_ID",') + print(' "kis-short": "단타_채널_ID",') + print(' "kis-long": "롱타_채널_ID"') + print(" }") + print(" }") + else: + print("❌ 저장 실패!") + + db.close() + +if __name__ == "__main__": + try: + update_env_config() + except KeyboardInterrupt: + print("\n\n❌ 사용자 취소") + except Exception as e: + print(f"\n\n❌ 에러: {e}") + import traceback + traceback.print_exc() diff --git a/update_env_short.py b/update_env_short.py new file mode 100644 index 0000000..7c9c44c --- /dev/null +++ b/update_env_short.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +단타 봇용 환경변수 DB 업데이트 스크립트 +- .env 파일의 단타 설정값을 DB에 저장 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def update_short_bot_env(): + """단타 봇 환경변수 업데이트""" + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + # 기존 최신 설정 불러오기 + latest = db.get_latest_env() + if latest and latest.get("snapshot"): + current = latest["snapshot"] + print(f"📋 기존 설정 불러옴 (ID: {latest['id']})") + else: + current = {} + print("⚠️ 기존 설정 없음 - 새로 생성") + + # 단타 봇 설정값 (.env 23-140줄 기준) + short_bot_updates = { + # ========== 기본 거래 설정 ========== + "MAX_STOCKS": "7", # 최대 보유 종목 수 + "STOP_LOSS_PCT": "-0.18", # 종목별 손절 기준 (-18%) + "USE_SLOT_CAP": "true", # 종목당 금액 상한(슬롯) 적용 + "SLOT_CAP_PCT": "0.9", # 슬롯 계산 시 예수금 반영 비율 (90%) + + # ========== 리스크 관리 (손실 한도) ========== + "USE_RISK_CHECK": "false", # 일일 손실 한도 체크 + "DAILY_STOP_LOSS_PCT": "-0.05", # 일일 손실 한도 (-5%) + "CONSECUTIVE_LOSS_LIMIT": "7", # 연속 손절 한도 (7회) + + # ========== 금지 종목 관리 (재매수 방지) ========== + "USE_BAN_SYSTEM": "true", # 금지 종목 관리 시스템 + "BAN_HOURS": "24", # 금지 시간 (24시간) + + # ========== 종목 필터링 (쓰레기 종목 제외) ========== + "USE_STOCK_FILTER": "true", # 종목 필터링 + + # ========== 작은 수익 보호 (30분 내) ========== + "USE_QUICK_PROFIT_PROTECTION": "true", # 작은 수익 보호 + + # ========== 리스크 관리 (Ver2 신규) ========== + "RISK_PCT_PER_TRADE": "0.015", # 1회 거래 시 허용 손실 비율 (1.5%) + "MAX_POSITION_PCT": "0.20", # 종목당 최대 투자 비중 (20%) + "MIN_POSITION_AMOUNT": "20000", # 최소 매수 금액 (2만원) + + # ========== 켈리 공식 (Ver2 신규) ========== + "USE_KELLY": "false", # 하프 켈리 공식 활성화 + + # ========== ML 승률 예측 (Ver2 신규 - 실험적!) ========== + "USE_ML_SIGNAL": "false", # ML 기반 매수 신호 필터링 + "ML_MIN_PROBABILITY": "0.57", # ML 승률 임계값 (57%) + + # ========== 뉴스 AI 분석 (Ver2 신규 - 참고용!) ========== + "USE_NEWS_ANALYSIS": "false", # 뉴스 AI 분석 활성화 + "NEWS_ANALYSIS_HOUR": "9", # 뉴스 분석 시간 (9시) + "NEWS_MAX_COUNT": "5", # 크롤링할 뉴스 개수 (5개) + + # ========== 랜덤 분할 매수 (Dual 방식 - 승률 최적화!) ========== + "USE_RANDOM_SPLIT": "true", # 랜덤 분할 매수 활성화 + + # ========== TWAP 분할 매수 (Ver2 신규) ========== + "USE_TWAP": "false", # TWAP 분할 매수 활성화 + "TWAP_MIN_SPLIT": "500000", # 1회 최소 주문 금액 (50만원) + "TWAP_MAX_SPLIT": "2000000", # 1회 최대 주문 금액 (200만원) + "TWAP_MIN_DELAY": "30", # 분할 매수 간 최소 딜레이 (초) + "TWAP_MAX_DELAY": "180", # 분할 매수 간 최대 딜레이 (초) + + # ========== 디버그용 장 상태 강제 오픈 ========== + "FORCE_MARKET_OPEN": "true", # 장 상태에 관계없이 항상 매매 로직 실행 (테스트용!) + + # ========== 매수 전략 파라미터 ========== + "RSI_OVERHEAT_THRESHOLD": "78", # RSI 과열 기준 (진입 보류 기준) + "SHOULDER_CUT_PCT": "0.03", # 어깨 매도 기준 (고점 대비 하락률) + + # ========== 🚨 피뢰침 방지 필터 (강화!) ========== + "HIGH_PRICE_CHASE_THRESHOLD": "0.96", # 일일 최고가 추격 매수 방지 기준 + "MAX_DAILY_CHANGE_PCT": "18.0", # 당일 최대 변동폭 (%) + "MA20_MAX_ABOVE_PCT": "8.8", # 20선 과열 방지: MA20 대비 이 % 초과 위면 매수 스킵 + + "MIN_RECOVERY_RATIO": "0.32", # 꼬리 잡기 최소 회복 비율 + "MAX_RECOVERY_RATIO": "1.15", # 꼬리 잡기 최대 회복 비율 + "CANDLE_OPEN_PRICE_BUFFER": "0.98", # 시가 대비 현재가 버퍼 (음봉 꼬리 위험 방지) + "VOLUME_AVG_MULTIPLIER": "0.50", # 거래량 평균 대비 배율 + + "INTRADAY_INVESTOR_NET_BUY_THRESHOLD": "-1000", # 장중 투자자 순매수 기준 + + # ========== ATR 배율 설정 ========== + "STOP_ATR_MULTIPLIER_TAIL": "2.5", # TAIL_CATCH_3M 전략 손절가 (ATR × 2.5) + "TARGET_ATR_MULTIPLIER_TAIL": "8.0", # TAIL_CATCH_3M 전략 목표가 (ATR × 8.0) + + # 단타 봇 전용 설정 (기존 값 유지 또는 기본값) + "TAKE_PROFIT_PCT": current.get("TAKE_PROFIT_PCT", "0.05"), # 익절 기준 (5%) + "MIN_DROP_RATE": current.get("MIN_DROP_RATE", "0.03"), # 최소 낙폭 (3%) + "MIN_RECOVERY_RATIO_SHORT": current.get("MIN_RECOVERY_RATIO_SHORT", "0.5"), # 최소 회복 비율 (50%) + } + + # 기존 값과 병합 (단타 봇 설정으로 덮어쓰기) + new_snapshot = {**current, **short_bot_updates} + + # 새 스냅샷 저장 + env_id = db.insert_env_snapshot(new_snapshot) + if env_id: + print(f"✅ 단타 봇 환경변수 저장 완료! (새 스냅샷 ID: {env_id})") + print() + print("📋 저장된 주요 설정:") + print(f" - MAX_STOCKS: {short_bot_updates['MAX_STOCKS']}") + print(f" - STOP_LOSS_PCT: {short_bot_updates['STOP_LOSS_PCT']}") + print(f" - USE_RANDOM_SPLIT: {short_bot_updates['USE_RANDOM_SPLIT']}") + print(f" - RSI_OVERHEAT_THRESHOLD: {short_bot_updates['RSI_OVERHEAT_THRESHOLD']}") + print(f" - FORCE_MARKET_OPEN: {short_bot_updates['FORCE_MARKET_OPEN']} (테스트용!)") + print() + print("💡 팁:") + print(" - 봇을 재시작하면 새 설정이 적용됩니다.") + print(" - FORCE_MARKET_OPEN=true는 테스트용이므로 실전에서는 false로 변경하세요.") + else: + print("❌ 저장 실패!") + + db.close() + return env_id + +if __name__ == "__main__": + try: + update_short_bot_env() + except Exception as e: + print(f"\n\n❌ 에러: {e}") + import traceback + traceback.print_exc() diff --git a/update_env_simple.py b/update_env_simple.py new file mode 100644 index 0000000..86ae4ce --- /dev/null +++ b/update_env_simple.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +환경변수 DB 업데이트 스크립트 (간단 버전) +- 딕셔너리로 직접 값을 넣어서 업데이트 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def update_env(updates): + """ + 환경변수 업데이트 + + Args: + updates: 업데이트할 환경변수 딕셔너리 + 예: { + "KIS_APP_KEY": "your_app_key", + "KIS_APP_SECRET": "your_app_secret", + "MM_BOT_TOKEN_": "your_token", + ... + } + """ + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + # 기존 최신 설정 불러오기 + latest = db.get_latest_env() + if latest and latest.get("snapshot"): + current = latest["snapshot"] + print(f"📋 기존 설정 불러옴 (ID: {latest['id']})") + else: + current = {} + print("⚠️ 기존 설정 없음 - 새로 생성") + + # 기존 값과 병합 + new_snapshot = {**current, **updates} + + # 새 스냅샷 저장 + env_id = db.insert_env_snapshot(new_snapshot) + if env_id: + print(f"✅ 환경변수 저장 완료! (새 스냅샷 ID: {env_id})") + print(f" 업데이트된 키: {', '.join(updates.keys())}") + else: + print("❌ 저장 실패!") + + db.close() + return env_id + +if __name__ == "__main__": + # 여기에 업데이트할 값들을 딕셔너리로 넣으세요 + my_updates = { + # 한투 API 설정 + + # "KIS_APP_KEY_REAL": "PSUbfJUp3eiA0rthF1GSK8yWI7dD7GvXMPQL", # 여기에 앱키 입력 + # "KIS_APP_SECRET_REAL": "DzG04RbksnUMROslum/2DliJiVZAdeSgwUNHKSbFehMmD2WKGVUeSd0N1B8LY947W/aNtEmU8pdkvKTFnVX1u68DvCj7cvEtlJc++wCUeaRD3z1Ov48b5PLsPiWvwE+pMd0pEl6jmFg0J6td1TidugAsZtEQ3GUBimyQyDSgw3jkdbnM390=", + # "KIS_APP_KEY_MOCK": "PSdfKtsMihgC9tLiUr2XISscuR3fHxl6kvmV", # 여기에 앱키 입력` + # "KIS_APP_SECRET_MOCK": "Ip+XZrZcoz11thgDD40XS8i6R1AalYkKFZwg2w8+ZMulVKN8rJVXiqGONxc4EYxw1S3TgOcx7fSldDc6EGq63bprfbgHwKWxstu29ZmLAtRNU0oFqV7e9vCOfgiWxrfnCqwcihoS7ovmza9+Ylqd8/EtjFGNmhQHWocyTAm8kdp5IG6tFtc=", # 여기에 앱시크릿 입력 + + # "KIS_ACCOUNT_NO_REAL": "44030801", + # "KIS_ACCOUNT_NO_MOCK": "50166974", + # "KIS_ACCOUNT_NO_MOCK": "44030801", + # "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, + + } + + # 빈 값 제거 (업데이트하지 않음) + my_updates = {k: v for k, v in my_updates.items() if v} + + if not my_updates: + print("⚠️ 업데이트할 값이 없습니다.") + print(" update_env_simple.py 파일을 열어서 my_updates 딕셔너리에 값을 입력하세요.") + sys.exit(1) + + update_env(my_updates)