Files
kis_bot/etf_ver1.py
2026-03-17 12:33:30 +09:00

888 lines
34 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
etf_ver1.py — ETF 액티브 매매 봇 (RSI 기반 분할 매수 & 슈팅 익절)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
역할: 테마성 ETF (원자력, 전력망 등) 의 눌림목 분할 매수와 슈팅 익절 전략
한투 API 연동, WebSocket 실시간 가격 수신, DB 기반 상태 관리
전략 개요:
- 매수: RSI 35/30/25 이하에서 3 분할 매수 (30%/30%/40%)
- 매도: 수익률 +4% 이상 또는 RSI 70 이상에서 전량 익절
- 손절: 평단가 대비 -10% 에서 전량 손절 (리스크 관리)
- 유니버스: DB(env) 에서 지정한 ETF 종목 목록
KIS API 준수:
- SafeRequest: HTTP 429 에러 대비 재시도 로직
- WebSocket: H0STCNT0 실시간 체결가 수신 (kis_ws.py)
- 메신저 알림: 텔레그램 (HTML), 매터모스트 (Markdown) 분리
- Atomic Save: 매매 발생 시 DB 즉시 저장 (재시작 안전성)
"""
import os
import json
import time
import random
import logging
import datetime
from pathlib import Path
from typing import List, Dict, Optional
import pandas as pd
import requests
from database import TradeDB, ENV_CONFIG_KEYS
# WebSocket 실시간 체결가 캐시
try:
from kis_ws import KISWebSocketPriceCache
_KIS_WS_AVAILABLE = True
except ImportError:
_KIS_WS_AVAILABLE = False
# 로깅 설정
logging.basicConfig(
format='[%(asctime)s] %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO,
)
logger = logging.getLogger("ETFActiveBot")
# 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")
# ==============================================================================
# [메신저 알림] 텔레그램 & 매터모스트
# ==============================================================================
def msg_tg(token: str, chat_id: str, message: str):
"""텔레그램 메시지 전송 (HTML 방식)"""
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML"
}
try:
requests.post(url, data=payload, timeout=5)
except Exception as e:
logger.debug(f"[텔레그램 전송 에러] {e}")
def msg_mm(webhook_url: str, message: str):
"""매터모스트 메시지 전송 (Markdown 방식)"""
payload = {"text": message}
try:
requests.post(webhook_url, json=payload, timeout=5)
except Exception as e:
logger.debug(f"[매터모스트 전송 에러] {e}")
# ==============================================================================
# [한투 API 클라이언트] REST API 안전성 관리
# ==============================================================================
class KISAPI:
"""
한국투자증권 REST API 클라이언트
- 429 에러 대비 재시도 로직
- 모의투자/실전투자 분리
"""
def __init__(self):
self.mock = get_env_bool("KIS_MOCK", True)
self.app_key = get_env_from_db("KIS_APP_KEY_MOCK" if self.mock else "KIS_APP_KEY_REAL", "")
self.app_secret = get_env_from_db("KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_SECRET_REAL", "")
self.base_url = (
"https://openapivts.koreainvestment.com:29443"
if self.mock
else "https://openapi.koreainvestment.com:9443"
)
# 계좌 정보
self.account_no = get_env_from_db("KIS_ACCOUNT_NO_MOCK" if self.mock else "KIS_ACCOUNT_NO_REAL", "")
self.account_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK" if self.mock else "KIS_ACCOUNT_CODE_REAL", "")
# 액세스 토큰
self.access_token = None
self.token_expiry = 0
# WebSocket 캐시 (ETF 는 REST 만 사용, 단타용과 공유 안함)
# ETF 는 일봉 RSI 기반, 1~3 분 주기 체크로 REST 로 충분
self.ws_cache = None
# if _KIS_WS_AVAILABLE:
# try:
# self.ws_cache = KISWebSocketPriceCache(
# app_key=self.app_key,
# app_secret=self.app_secret,
# is_mock=self.mock
# )
# if self.ws_cache.start(force_cleanup=True):
# logger.info("✅ WebSocket 활성 (force_cleanup=True, 비정상 종료 대비)")
# else:
# logger.info(" WebSocket 비활성 (모의 or 키 미설정) → REST fallback")
# except Exception as e:
# logger.warning(f"⚠️ WebSocket 초기화 예외: {e}")
# self.ws_cache = None
def get_access_token(self) -> str:
"""액세스 토큰 발급 (유효기간 23 시간)"""
now = time.time()
if self.access_token and now < self.token_expiry:
return self.access_token
try:
url = f"{self.base_url}/oauth2/token"
data = {
"grant_type": "client_credentials",
"appkey": self.app_key,
"secretkey": self.app_secret,
}
response = requests.post(url, json=data, timeout=10)
data = response.json()
self.access_token = data.get("access_token")
self.token_expiry = now + 82800 # 23 시간
logger.info(f"✅ 액세스 토큰 발급 완료 (앞 8 자: {self.access_token[:8]}...)")
return self.access_token
except Exception as e:
logger.error(f"❌ 토큰 발급 실패: {e}")
raise
def request(self, method: str, url: str, headers: dict = None, data: dict = None, params: dict = None, max_retries: int = 3):
"""
HTTP 요청 (429 에러 재시도 로직 포함)
Args:
method: HTTP 메서드 (GET, POST 등)
url: 요청 URL
headers: HTTP 헤더
data: 요청 바디
params: 쿼리 파라미터
max_retries: 최대 재시도 횟수
"""
for attempt in range(max_retries):
try:
response = requests.request(
method=method,
url=url,
headers=headers,
json=data,
params=params,
timeout=10
)
# HTTP 429 (Too Many Requests)
if response.status_code == 429:
wait_time = (attempt + 1) * 2
logger.warning(f"⚠️ API 제한 (429) → {wait_time}초 대기 후 재시도...")
time.sleep(wait_time)
continue
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
logger.error(f"❌ HTTP 에러: {e}")
if attempt >= max_retries - 1:
raise
time.sleep(2)
except Exception as e:
logger.error(f"❌ 요청 실패: {e}")
if attempt >= max_retries - 1:
raise
time.sleep(2)
raise Exception("API 요청 최대 재시도 횟수 초과")
def get_stock_price(self, code: str) -> Optional[float]:
"""
주식/ETF 현재가 조회 (WebSocket 우선, fallback REST)
Args:
code: 종목코드 (6 자리)
Returns:
현재가 (float) 또는 None
"""
# WebSocket 캐시 확인
if self.ws_cache and self.ws_cache.is_active:
ws_data = self.ws_cache.get_price(code, max_age_sec=5.0)
if ws_data:
price = float(ws_data.get("stck_prpr", 0))
if price > 0:
logger.debug(f"📡 WebSocket 가격 수신: {code}{price:,.0f}")
return price
# REST fallback
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "FHKST01010100",
}
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
}
result = self.request("GET", url, headers=headers, params=params)
output = result.get("output", {})
price = float(output.get("stck_prpr", 0))
if price > 0:
logger.debug(f"📡 REST 가격 수신: {code}{price:,.0f}")
return price
except Exception as e:
logger.error(f"❌ 현재가 조회 실패 ({code}): {e}")
return None
def get_account_balance(self) -> Dict:
"""
계좌 잔고 조회
Returns:
{'cash': float, 'total_asset': float, 'holdings': {code: {'qty': int, 'avg_price': float}}}
"""
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/inquire-balance"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "TTDO84013", # 모의투자 잔고조회
}
params = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"AFHR_FLPR_YN": "N",
"OFL_YN": "",
"INQR_DVSN": "01",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNAG_AMT_AUTO_INPT_STPL_YN": "N",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
result = self.request("GET", url, headers=headers, params=params)
output = result.get("output", {})
cash = float(output.get("prvs_rcdl_excc_amt", 0))
total_asset = float(output.get("tot_asst_amt", 0))
holdings = {}
for item in output.get("fdtl_invest_item_lst", []):
code = item.get("pdno", "")
qty = int(item.get("hldg_qty", 0))
avg_price = float(item.get("pchs_amt", 0)) / qty if qty > 0 else 0
if code and qty > 0:
holdings[code] = {'qty': qty, 'avg_price': avg_price}
return {'cash': cash, 'total_asset': total_asset, 'holdings': holdings}
except Exception as e:
logger.error(f"❌ 잔고 조회 실패: {e}")
return {'cash': 0, 'total_asset': 0, 'holdings': {}}
def buy(self, code: str, qty: int, price: Optional[int] = None) -> bool:
"""
주식/ETF 매수 주문
Args:
code: 종목코드
qty: 매수 수량
price: 매수가 (None 이면 시장가)
Returns:
성공 시 True
"""
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "VTTC0802U", # 모의투자 현금매수
}
# 시장가 주문 (ETF 는 호가단위 없음)
if price is None:
ord_pric = "0"
ord_dvsn = "01" # 시장가
else:
ord_pric = str(price)
ord_dvsn = "00" # 지정가
data = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"PDNO": code,
"ORD_DVSN": ord_dvsn,
"ORD_QTY": str(qty),
"ORD_UNPR": ord_pric,
}
result = self.request("POST", url, headers=headers, data=data)
if result.get("rt_cd") == "0":
logger.info(f"✅ 매수 주문 성공: {code} {qty}")
return True
else:
logger.error(f"❌ 매수 주문 실패: {result.get('msg1', '')}")
return False
except Exception as e:
logger.error(f"❌ 매수 주문 예외: {e}")
return False
def sell(self, code: str, qty: int, price: Optional[int] = None) -> bool:
"""
주식/ETF 매도 주문
Args:
code: 종목코드
qty: 매도 수량
price: 매도가 (None 이면 시장가)
Returns:
성공 시 True
"""
try:
token = self.get_access_token()
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"secretkey": self.app_secret,
"tr_id": "VTTC0801U", # 모의투자 현금매도
}
if price is None:
ord_pric = "0"
ord_dvsn = "01" # 시장가
else:
ord_pric = str(price)
ord_dvsn = "00" # 지정가
data = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"PDNO": code,
"ORD_DVSN": ord_dvsn,
"ORD_QTY": str(qty),
"ORD_UNPR": ord_pric,
}
result = self.request("POST", url, headers=headers, data=data)
if result.get("rt_cd") == "0":
logger.info(f"✅ 매도 주문 성공: {code} {qty}")
return True
else:
logger.error(f"❌ 매도 주문 실패: {result.get('msg1', '')}")
return False
except Exception as e:
logger.error(f"❌ 매도 주문 예외: {e}")
return False
# ==============================================================================
# [핵심 매매 로직] ETF 액티브 (RSI 기반 분할 매수)
# ==============================================================================
class ETFActiveTrader:
"""
ETF 액티브 매매 트레이더
사용법:
trader = ETFActiveTrader()
trader.run()
"""
def __init__(self):
self.api = KISAPI()
self.db = db
# ETF 유니버스 (DB 에서 로드)
self.etf_universe = self._load_etf_universe()
# 활성 트레이딩 상태 (DB 에서 복원)
self.active_trades = self.db.get_active_trades(strategy_prefix="ETF")
# 메신저 설정
self.mm_token = get_env_from_db("MM_BOT_TOKEN_", "")
self.mm_channel = get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock")
self.mm_server = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org")
logger.info(f"✅ ETF 액티브 트레이더 초기화 완료")
logger.info(f" 유니버스: {len(self.etf_universe)}개 종목")
logger.info(f" 활성 트레이딩: {len(self.active_trades)}")
def _load_etf_universe(self) -> List[str]:
"""
DB 에서 ETF 유니버스 로드
Returns:
ETF 종목코드 목록
"""
# 예시: env 에서 "ETF_UNIVERSE" 키로 콤마 구분된 종목코드 로드
# 예: "069500,114800,280670" (KODEX 200, KODEX 은행, KODEX 원자력)
etf_list_str = get_env_from_db("ETF_UNIVERSE", "069500,114800,280670")
etf_codes = [code.strip() for code in etf_list_str.split(",") if code.strip()]
if not etf_codes:
logger.warning("⚠️ ETF 유니버스가 비어있습니다. 기본값을 사용합니다.")
etf_codes = ["069500", "114800", "280670"] # KODEX 200, 은행, 원자력
logger.info(f"📊 ETF 유니버스 로드: {etf_codes}")
return etf_codes
def calculate_rsi(self, prices: List[float], period: int = 14) -> Optional[float]:
"""
RSI 계산 (단순 이동평균 방식)
Args:
prices: 종가 목록 (최신순)
period: RSI 기간
Returns:
RSI 값 또는 None
"""
if len(prices) < period + 1:
return None
# 최근 N 일 사용
prices = prices[:period+1][::-1] # 오름차순 정렬
gains = []
losses = []
for i in range(1, len(prices)):
delta = prices[i] - prices[i-1]
if delta > 0:
gains.append(delta)
losses.append(0)
else:
gains.append(0)
losses.append(abs(delta))
avg_gain = sum(gains) / period
avg_loss = sum(losses) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
def get_daily_prices(self, code: str, days: int = 30) -> List[float]:
"""
일봉 종가 조회 (최근 N 일)
Args:
code: 종목코드
days: 일수
Returns:
종가 목록 (최신순)
"""
try:
# 한투 API 일봉 조회
token = self.api.get_access_token()
url = f"{self.api.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
headers = {
"Content-Type": "application/json",
"authorization": f"Bearer {token}",
"appkey": self.api.app_key,
"secretkey": self.api.app_secret,
"tr_id": "FHKST03010100",
}
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
"FID_INPUT_DATE_1": "",
"FID_INPUT_DATE_2": "",
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "0",
}
result = self.api.request("GET", url, headers=headers, params=params)
output = result.get("output", {})
data_list = output.get("output2", [])
prices = []
for item in data_list[:days]:
close = float(item.get("stck_clpr", 0))
if close > 0:
prices.append(close)
return prices
except Exception as e:
logger.error(f"❌ 일봉 조회 실패 ({code}): {e}")
return []
def send_notification(self, title: str, content: str):
"""메신저 알림 발송"""
# 텔레그램
if self.mm_token:
tg_msg = f"<b>[ETF 액티브]</b> {title}\n\n{content}"
msg_tg(self.mm_token, self.mm_channel, tg_msg)
# 매터모스트
mm_msg = f"**[ETF 액티브]** {title}\n\n{content}"
msg_mm(f"{self.mm_server}/hooks/{self.mm_token}", mm_msg)
def run(self):
"""메인 루프 (최적화 + 매수/매도 동기화)"""
logger.info("\n=== [ETF 액티브 매매] 메인 루프 시작 ===\n")
# [최적화] 일봉 데이터는 10 분마다 갱신 (캐시)
daily_price_cache = {}
last_price_cache_update = 0
while True:
try:
now = time.time()
# [최적화 1] 일봉 데이터 10 분마다 갱신
if now - last_price_cache_update > 600: # 600 초 = 10 분
for code in self.etf_universe + list(self.active_trades.keys()):
prices = self.get_daily_prices(code, days=30)
if prices:
daily_price_cache[code] = prices
last_price_cache_update = now
logger.debug(f"📊 일봉 데이터 갱신: {len(daily_price_cache)}종목")
# 1. 활성 트레이딩 상태 업데이트 (매도 판단)
for code in list(self.active_trades.keys()):
self._update_active_trade(code, daily_price_cache)
# 2. 매수 기회 탐색 (RSI 35 이하)
for code in self.etf_universe:
if code not in self.active_trades:
self._check_buy_opportunity(code, daily_price_cache)
# 3. 서버 부하 방지 (1~3 분 대기)
sleep_time = random.uniform(60, 180)
logger.info(f"💤 {sleep_time/60:.1f}분 대기...")
time.sleep(sleep_time)
except KeyboardInterrupt:
logger.info("🛑 사용자 요청으로 매매 중단")
break
except Exception as e:
logger.error(f"❌ 메인 루프 예외: {e}")
time.sleep(60)
def _update_active_trade(self, code: str, daily_price_cache: Dict = None):
"""
활성 트레이딩 상태 업데이트 (매도 판단)
Args:
code: 종목코드
daily_price_cache: 일봉 데이터 캐시 (최적화용)
"""
trade = self.active_trades.get(code)
if not trade:
return
# 현재가 조회 (WebSocket 또는 REST)
current_price = self.api.get_stock_price(code)
if not current_price:
return
# 평균 단가
avg_price = trade.get('avg_buy_price', 0)
if avg_price <= 0:
return
# 수익률
profit_rate = (current_price - avg_price) / avg_price
# 일봉으로 RSI 계산 (캐시 사용)
prices = daily_price_cache.get(code) if daily_price_cache else None
if not prices:
prices = self.get_daily_prices(code, days=30)
rsi = self.calculate_rsi(prices) if prices else None
logger.info(f"📊 {code} 현재: {current_price:,.0f}원 | 수익률: {profit_rate*100:.2f}% | RSI: {rsi}")
# [매도 판단]
sell_reason = None
# 1. 익절: +4% 이상 또는 RSI 70 이상
if profit_rate >= 0.04:
sell_reason = "슈팅 익절 (+4%)"
elif rsi and rsi >= 70:
sell_reason = f"RSI 과열 ({rsi:.1f})"
# 2. 손절: -10% 이하
elif profit_rate <= -0.10:
sell_reason = "안전 손절 (-10%)"
# 매도 실행
if sell_reason:
self._execute_sell(code, trade, current_price, sell_reason)
def _execute_sell(self, code: str, trade: Dict, current_price: float, reason: str):
"""
매도 실행
Args:
code: 종목코드
trade: 트레이드 정보
current_price: 현재가
reason: 매도 사유
"""
qty = trade.get('qty', 0)
avg_price = trade.get('avg_buy_price', 0)
if qty <= 0:
logger.warning(f"⚠️ 매도 수량 없음: {code}")
return
# 시장가 매도
success = self.api.sell(code, qty)
if success:
# 손익 계산
profit_loss = (current_price - avg_price) * qty
# DB 에서 제거
self.db.close_trade(
code=code,
sell_price=current_price,
sell_reason=reason,
size_class=trade.get('size_class')
)
# 활성 트레이딩에서 제거
del self.active_trades[code]
logger.info(f"✅ [{code}] 매도 완료: {qty}주 @ {current_price:,.0f}원 | {reason} | 손익: {profit_loss:,.0f}")
# 메신저 알림
self.send_notification(
f"매도 완료: {trade.get('name', code)}",
f"▪️ 종목: {code}\n"
f"▪️ 수량: {qty}\n"
f"▪️ 가격: {current_price:,.0f}\n"
f"▪️ 사유: {reason}\n"
f"▪️ 손익: {profit_loss:,.0f}"
)
def _check_buy_opportunity(self, code: str, daily_price_cache: Dict = None):
"""
매수 기회 탐색 (RSI 기반 3 분할 매수)
Args:
code: 종목코드
daily_price_cache: 일봉 데이터 캐시 (최적화용)
"""
# 이미 보유 중인 종목은 추가 매수만 고려
is_additional_buy = code in self.active_trades
# 일봉으로 RSI 계산 (캐시 사용)
prices = daily_price_cache.get(code) if daily_price_cache else None
if not prices:
prices = self.get_daily_prices(code, days=30)
if not prices or len(prices) < 15:
return
rsi = self.calculate_rsi(prices)
if rsi is None:
return
current_price = self.api.get_stock_price(code)
if not current_price:
return
logger.debug(f"🔍 {code} 확인: RSI={rsi:.1f} | 가격={current_price:,.0f}원 | 보유여부: {is_additional_buy}")
# [중요] 매수 직전 계좌 잔고 확인 (즉시 동기화)
# 매수/매도 시마다 계좌와 동기화되므로 여기서 반드시 확인
balance = self.api.get_account_balance()
cash = balance.get('cash', 0)
if cash < current_price * 10: # 최소 10 주 매수 가능 현금
logger.warning(f"⚠️ 현금 부족: {cash:,.0f}")
return
# [1 차 매수] RSI 35 이하 && 보유 없음
if not is_additional_buy and rsi < 35:
target_amount = cash * 0.3 # 1 차 매수 금액 (자본금의 30%)
target_qty = int(target_amount / current_price)
if target_qty < 10:
logger.warning(f"⚠️ 매수 수량 부족: {target_qty}")
return
# 시장가 매수
success = self.api.buy(code, target_qty)
if success:
# DB 에 등록 (평단가 = 현재가)
trade_data = {
'code': code,
'name': f"ETF-{code}",
'strategy': 'ETF_ACTIVE',
'avg_buy_price': current_price, # 1 차는 현재가가 평단가
'current_price': current_price,
'target_qty': target_qty * 3, # 3 분할 목표
'current_qty': target_qty,
'total_invested': current_price * target_qty,
'status': 'BUYING',
'size_class': 'MID',
'rsi': rsi,
}
self.db.upsert_trade(trade_data)
self.active_trades[code] = trade_data
logger.info(f"✅ [{code}] 1 차 매수: {target_qty}주 @ {current_price:,.0f}원 | RSI={rsi:.1f}")
# 메신저 알림
self.send_notification(
f"1 차 매수: {trade_data['name']}",
f"▪️ 종목: {code}\n"
f"▪️ 수량: {target_qty}\n"
f"▪️ 가격: {current_price:,.0f}\n"
f"▪️ RSI: {rsi:.1f}\n"
f"▪️ 현금: {cash:,.0f}"
)
# [2 차, 3 차 매수] 보유 중인 종목 && RSI 추가 하락
elif is_additional_buy:
trade = self.active_trades[code]
current_qty = trade.get('current_qty', 0)
target_qty = trade.get('target_qty', 0)
avg_price = trade.get('avg_buy_price', 0)
# 2 차 매수: RSI 30 이하 && 1 차 수량보다 적을 때
if rsi < 30 and current_qty < target_qty * 0.6:
add_amount = cash * 0.3 # 2 차 매수 금액 (자본금의 30%)
add_qty = int(add_amount / current_price)
if add_qty < 10:
return
success = self.api.buy(code, add_qty)
if success:
# 평단가 재계산: (기존 투자금 + 추가 투자금) / (기존 수량 + 추가 수량)
new_total_invested = (avg_price * current_qty) + (current_price * add_qty)
new_total_qty = current_qty + add_qty
new_avg_price = new_total_invested / new_total_qty if new_total_qty > 0 else 0
# DB 업데이트
trade['avg_buy_price'] = new_avg_price
trade['current_qty'] = new_total_qty
trade['total_invested'] = new_total_invested
trade['rsi'] = rsi
self.db.upsert_trade(trade)
self.active_trades[code] = trade
logger.info(f"✅ [{code}] 2 차 매수: {add_qty}주 @ {current_price:,.0f}원 | RSI={rsi:.1f} | 평단가: {new_avg_price:,.0f}")
# 메신저 알림
self.send_notification(
f"2 차 매수 (물타기): {trade['name']}",
f"▪️ 종목: {code}\n"
f"▪️ 추가수량: {add_qty}\n"
f"▪️ 총수량: {new_total_qty}\n"
f"▪️ 현재가: {current_price:,.0f}\n"
f"▪️ 평단가: {new_avg_price:,.0f}원 (기존: {avg_price:,.0f}원)\n"
f"▪️ RSI: {rsi:.1f}"
)
# 3 차 매수: RSI 25 이하 && 2 차 수량보다 적을 때
elif rsi < 25 and current_qty < target_qty * 0.9:
add_amount = cash * 0.4 # 3 차 매수 금액 (자본금의 40%)
add_qty = int(add_amount / current_price)
if add_qty < 10:
return
success = self.api.buy(code, add_qty)
if success:
# 평단가 재계산
new_total_invested = (avg_price * current_qty) + (current_price * add_qty)
new_total_qty = current_qty + add_qty
new_avg_price = new_total_invested / new_total_qty if new_total_qty > 0 else 0
# DB 업데이트
trade['avg_buy_price'] = new_avg_price
trade['current_qty'] = new_total_qty
trade['total_invested'] = new_total_invested
trade['status'] = 'HOLDING' # 3 차까지 완료
trade['rsi'] = rsi
self.db.upsert_trade(trade)
self.active_trades[code] = trade
logger.info(f"✅ [{code}] 3 차 매수: {add_qty}주 @ {current_price:,.0f}원 | RSI={rsi:.1f} | 평단가: {new_avg_price:,.0f}")
# 메신저 알림
self.send_notification(
f"3 차 매수 (풀매수): {trade['name']}",
f"▪️ 종목: {code}\n"
f"▪️ 추가수량: {add_qty}\n"
f"▪️ 총수량: {new_total_qty}\n"
f"▪️ 현재가: {current_price:,.0f}\n"
f"▪️ 평단가: {new_avg_price:,.0f}\n"
f"▪️ RSI: {rsi:.1f}\n"
f"▪️ 3 분할 완료!"
)
# ==============================================================================
# [실행부]
# ==============================================================================
if __name__ == "__main__":
trader = ETFActiveTrader()
trader.run()