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

3180 lines
149 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.
"""
Kiwoom Trading Bot Ver2 - DB 기반 고급 트레이딩 시스템
- SQLite DB 기반 안전한 데이터 관리
- 변동성 기반 자금 관리 (Risk Manager)
- TWAP 스마트 분할 매수 (Smart Executor)
- 하프 켈리 공식 적용
- 기존 TAIL_CATCH_3M 전략 유지 및 강화
"""
import time
import json
import datetime
import asyncio
import pandas as pd
import numpy as np
import os
import logging
import requests
import random
from dotenv import load_dotenv
# 새로운 모듈 임포트
from database import TradeDB
from risk_manager import RiskManager
from smart_executor import SmartOrderExecutor
from ml_predictor import MLPredictor
from news_analyzer import NewsAnalyzer
from trend_divergence import TrendDivergenceAnalyzer
from export_sniper import ExportSniper
# ==========================================================
# [Step 0] 환경 변수 및 기본 설정
# ==========================================================
current_dir = os.path.dirname(os.path.abspath(__file__))
env_path = os.path.join(current_dir, ".env")
if not os.path.exists(env_path):
env_path = os.path.join(os.path.dirname(current_dir), ".env")
load_dotenv(env_path)
# Mattermost 설정
MM_SERVER_URL = "https://mattermost.hoonfam.org"
MM_BOT_TOKEN = os.environ.get("MM_BOT_TOKEN_", "").strip()
MM_CONFIG_FILE = os.path.join(current_dir, "mm_config.json")
# [Logger 설정]
logging.basicConfig(
format='[%(asctime)s] %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO
)
logger = logging.getLogger("TradingBotV2")
# 외부 라이브러리 로그 억제
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
# ==========================================================
# [Helper 함수] 환경변수 안전 로드
# ==========================================================
def get_env_float(key, default):
"""환경변수를 float로 변환 (주석 제거)"""
value = os.environ.get(key, default)
# 주석 제거 (# 이후 문자열 제거)
if isinstance(value, str) and "#" in value:
value = value.split("#")[0].strip()
return float(value)
def get_env_int(key, default):
"""환경변수를 int로 변환 (주석 제거)"""
value = os.environ.get(key, default)
# 주석 제거
if isinstance(value, str) and "#" in value:
value = value.split("#")[0].strip()
return int(value)
# 키움 API 모듈 임포트
try:
from kiwoom_rest_api.auth.token import TokenManager
from kiwoom_rest_api.koreanstock.stockinfo import StockInfo
from kiwoom_rest_api.koreanstock.chart import Chart
from kiwoom_rest_api.koreanstock.order import Order
from kiwoom_rest_api.koreanstock.rank_info import RankInfo
from kiwoom_rest_api.koreanstock.account import Account
from kiwoom_rest_api.koreanstock.market_condition import MarketCondition
# 퀀트 트레이딩 필수 API 추가
from kiwoom_rest_api.koreanstock.sector import Sector
from kiwoom_rest_api.koreanstock.foreign_institution import ForeignInstitution
from kiwoom_rest_api.koreanstock.theme import Theme
from kiwoom_rest_api.koreanstock.etf import ETF
except ImportError as e:
logger.critical(f"❌ 키움 REST API 모듈 임포트 실패: {e}")
raise e
# ==========================================================
# [Part 0] Mattermost 봇 클래스
# ==========================================================
class MattermostBot:
def __init__(self):
self.api_url = f"{MM_SERVER_URL.rstrip('/')}/api/v4/posts"
self.headers = {
"Authorization": f"Bearer {MM_BOT_TOKEN}",
"Content-Type": "application/json"
}
self.channels = self._load_channels()
def _load_channels(self):
try:
if os.path.exists(MM_CONFIG_FILE):
with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f).get("channels", {})
return {}
except Exception as e:
logger.error(f"⚠️ MM 설정 로드 실패: {e}")
return {}
def send(self, channel_alias, message):
channel_id = self.channels.get(channel_alias)
if not channel_id:
logger.warning(f"'{channel_alias}' 채널 ID 없음")
return False
payload = {"channel_id": channel_id, "message": message}
try:
res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3)
res.raise_for_status()
return True
except Exception as e:
logger.error(f"❌ MM 전송 에러: {e}")
return False
# ==========================================================
# [Part 1] 브로커 API (키움증권 REST API 연동)
# ==========================================================
class BrokerAPI:
def __init__(self):
logger.info("🔵 키움(REST) 브로커 연결 시도...")
try:
self.token_manager = TokenManager()
self.stock_info = StockInfo(token_manager=self.token_manager)
self.chart = Chart(token_manager=self.token_manager)
self.order = Order(token_manager=self.token_manager)
self.rank = RankInfo(token_manager=self.token_manager)
self.account = Account(token_manager=self.token_manager)
self.market = MarketCondition(token_manager=self.token_manager)
# 퀀트 트레이딩 필수 API 추가
self.sector = Sector(token_manager=self.token_manager)
self.foreign_inst = ForeignInstitution(token_manager=self.token_manager)
self.theme = Theme(token_manager=self.token_manager)
self.etf = ETF(token_manager=self.token_manager)
self.acc_no = os.environ.get("KIWOOM_ACCOUNT_NO", "")
logger.info(f"✅ 브로커 연결 완료 (계좌: {self.acc_no})")
except Exception as e:
logger.critical(f"❌ 브로커 초기화 실패: {e}")
raise e
def _safe_request(self, func, *args, **kwargs):
"""API 호출 안전장치 (429 / 과부하 핸들링)
- 키움 REST API의 호출 초과(429, '초과', '과부하')를 감지
- 지수 백오프(1s → 2s → 4s)에 약간의 지터(jitter)를 섞어 재시도
- 기존 로직과 동일하게, 최대 재시도 후에는 {} 반환
"""
full_name = func.__name__
api_id = full_name.split('_')[-1]
max_retries = 3
logger.debug(f"💀 [{api_id}] _safe_request 호출")
for i in range(max_retries):
try:
# 기본 안전 대기 (서버 부하 완화용)
time.sleep(1)
result = func(*args, **kwargs)
# 키움 REST는 HTTP 200 + return_code/msg1 조합으로 "호출 초과"를 알리는 경우가 있음
if isinstance(result, dict):
msg1 = str(result.get('msg1', ''))
return_code = str(result.get('return_code', '0'))
# '초과', '과부하' 등의 키워드로 레이트 리밋/과부하 감지
if return_code != '0' and ('초과' in msg1 or '과부하' in msg1):
raise Exception(f"RateLimitOrOverload: {msg1}")
return result
except Exception as e:
msg = str(e)
# 429 / 호출 초과 / 과부하 계열 에러: 지수 백오프 + 지터
if ("429" in msg) or ("RateLimit" in msg) or ("초과" in msg) or ("과부하" in msg):
# 1, 2, 4초 + 약간의 랜덤 지터 (0.5~1.5초)
base = 2 ** i
wait = base + random.uniform(0.5, 1.5)
logger.warning(
f"⚠️ [{api_id}] API 호출 제한 또는 과부하 감지 -> {wait:.1f}초 대기 후 재시도 "
f"({i + 1}/{max_retries}) | 에러: {msg}"
)
time.sleep(wait)
else:
# 그 외 에러는 기존처럼 짧게 대기 후 다음 루프
logger.error(f"❌ [{api_id}] 호출 에러: {e}")
time.sleep(1)
logger.error(f"💀 [{api_id}] 3회 재시도 실패")
return {}
def get_deposit_only(self):
"""예수금 조회"""
try:
res = self._safe_request(self.account.deposit_detail_status_request_kt00001, qry_tp="2")
d2_deposit = float(res.get('d2_entra', 0)) if res else 0
current_deposit = float(res.get('ord_alow_amt', 0)) if res else 0
return 0 if d2_deposit < 0 else current_deposit
except Exception as e:
logger.error(f"예수금 조회 실패: {e}")
return 0
def get_account_info(self):
"""계좌 평가 정보 조회 (전체 자산, 주식 평가액, 보유 종목 리스트)"""
try:
# 예수금 조회
deposit = self.get_deposit_only()
# 잔고 조회 (보유 종목 리스트 포함!)
res = self._safe_request(
self.account.account_evaluation_balance_detail_request_kt00018,
query_type="1", # 1:합산, 2:개별
domestic_exchange_type="KRX" # 한국거래소
)
if not res:
return {
'total_asset': deposit,
'deposit': deposit,
'stock_value': 0,
'holdings': {} # 🔥 보유 종목 리스트 추가
}
# 주식평가금액
stock_value = float(res.get('tot_evlt_amt', 0))
# 총자산 = 예수금 + 주식평가금액
total_asset = deposit + stock_value
# 🔥 보유 종목 리스트 파싱 (Dual 로직 이식!)
holdings = {}
if 'acnt_evlt_remn_indv_tot' in res:
for item in res['acnt_evlt_remn_indv_tot']:
code = item['stk_cd'].strip()
if code.startswith('A'):
code = code[1:] # 'A005930' → '005930'
qty = int(item.get('rmnd_qty', 0))
if qty <= 0:
continue # 수량 0인 종목 제외
holdings[code] = {
'name': item['stk_nm'].strip(),
'qty': qty,
'buy_price': abs(float(item.get('pur_pric', 0))),
'current_price': abs(float(item.get('cur_prc', 0))),
'profit_rate': float(item.get('erng_rt', 0))
}
return {
'total_asset': total_asset,
'deposit': deposit,
'stock_value': stock_value,
'holdings': holdings # 🔥 {code: {name, qty, buy_price, current_price, profit_rate}}
}
except Exception as e:
logger.error(f"계좌 정보 조회 실패: {e}")
import traceback
logger.error(f"상세 오류:\n{traceback.format_exc()}")
return {
'total_asset': 0,
'deposit': 0,
'stock_value': 0,
'holdings': {}
}
def check_market_status(self):
"""장 운영 시간 체크 (08:30 ~ 16:00)"""
now = datetime.datetime.now()
if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)):
return False
if now.weekday() >= 5: # 주말
return False
return True
def get_ohlcv(self, code, timeframe='3m', limit=100):
"""분봉 차트 데이터 조회"""
tic_scope = {"1m": "1", "3m": "3", "5m": "5", "10m": "10"}.get(timeframe, "3")
try:
res = self._safe_request(
self.chart.stock_minute_chart_request_ka10080,
stk_cd=code, tic_scope=tic_scope, upd_stkpc_tp="1"
)
data = res.get('stk_min_pole_chart_qry', []) if res else []
if not data:
return pd.DataFrame()
df = pd.DataFrame(data)
df = df.rename(columns={
'cur_prc': 'close', 'open_pric': 'open',
'high_pric': 'high', 'low_pric': 'low',
'trde_qty': 'volume'
})
# 시간순 정렬 (과거->현재)
df = df[['open', 'high', 'low', 'close', 'volume']].astype(float).abs()
return df.iloc[::-1].reset_index(drop=True).tail(limit)
except Exception as e:
logger.error(f"차트 조회 실패({code}): {e}")
return pd.DataFrame()
def get_current_price(self, code):
"""현재가 조회"""
try:
res = self._safe_request(
self.stock_info.watchlist_stock_information_request_ka10095,
stock_code=code
)
if res and 'atn_stk_infr' in res and len(res['atn_stk_infr']) > 0:
item = res['atn_stk_infr'][0]
return abs(float(item.get('cur_prc', 0)))
return None
except Exception as e:
logger.error(f"현재가 조회 에러({code}): {e}")
return None
def buy_market_order(self, code, qty):
"""시장가 매수 주문"""
try:
res = self._safe_request(
self.order.stock_buy_order_request_kt10000,
dmst_stex_tp="KRX", stk_cd=code,
ord_qty=str(qty), trde_tp="3", ord_uv="0"
)
if str(res.get('return_code')) == '0':
return True
logger.error(f"매수 주문 실패({code}): {res}")
return False
except Exception as e:
logger.error(f"매수 주문 예외({code}): {e}")
return False
def sell_market_order(self, code, qty):
"""시장가 매도 주문"""
try:
res = self._safe_request(
self.order.stock_sell_order_request_kt10001,
dmst_stex_tp="KRX", stk_cd=code,
ord_qty=str(qty), trde_tp="3", ord_uv="0"
)
if str(res.get('return_code')) == '0':
return True
logger.error(f"매도 주문 실패({code}): {res}")
return False
except Exception as e:
logger.error(f"매도 주문 예외({code}): {e}")
return False
# ==========================================================
# [퀀트 분석 메소드] - Sector (업종 분석)
# ==========================================================
def get_top_sectors(self, market="001", top_n=5):
"""상승률 상위 업종 조회"""
try:
res = self._safe_request(
self.sector.all_industries_index_request_ka20003,
mrkt_tp=market # 001:코스피, 101:코스닥
)
if not res:
return []
# 업종 데이터 정렬 (상승률 기준)
sectors = res.get('all_indx_qry', [])
sorted_sectors = sorted(sectors,
key=lambda x: float(x.get('fluc_rt', 0)),
reverse=True)
return sorted_sectors[:top_n]
except Exception as e:
logger.error(f"업종 조회 실패: {e}")
return []
def get_sector_investor_trend(self, market="001"):
"""업종별 투자자 순매수 현황"""
try:
res = self._safe_request(
self.sector.industrywise_investor_net_buy_request_ka10051,
mrkt_tp=market, # 001:코스피, 101:코스닥
stex_tp="1" # 1:KRX
)
return res.get('indiv_nvst_netby_indu', []) if res else []
except Exception as e:
logger.error(f"업종 투자자 동향 조회 실패: {e}")
return []
# ==========================================================
# [퀀트 분석 메소드] - ForeignInstitution (수급 분석)
# ==========================================================
def get_foreign_consecutive_buy(self, consecutive_days=3, market="001", limit=20):
"""외국인 연속 순매수 종목"""
try:
res = self._safe_request(
self.rank.top_foreign_consecutive_net_buy_request_ka10035,
mrkt_tp=market,
trde_tp="2", # 1:연속순매도, 2:연속순매수
base_dt_tp="1", # 0:당일기준, 1:전일기준
stex_tp="1" # 1:KRX
)
stocks = res.get('for_cont_nettrde_upper', []) if res else []
return stocks[:limit]
except Exception as e:
logger.error(f"외국인 연속 순매수 조회 실패: {e}")
return []
def get_institutional_buy_stocks(self, market="001", limit=20):
"""기관 순매수 상위 종목"""
try:
from datetime import datetime as dt
today = dt.now().strftime("%Y%m%d")
res = self._safe_request(
self.rank.same_day_net_buying_ranking_request_ka10062,
strt_dt=today, # 시작일자 (YYYYMMDD)
mrkt_tp=market, # 시장구분
trde_tp="1", # 1:순매수, 2:순매도
sort_cnd="1", # 1:수량, 2:금액
unit_tp="1", # 1:단주, 1000:천주
stex_tp="1" # 1:KRX
)
stocks = res.get('eql_nettrde_rank', []) if res else []
# 기관 순매수만 필터 (orgn_nettrde_qty > 0)
return [s for s in stocks if float(s.get('orgn_nettrde_qty', 0)) > 0][:limit]
except Exception as e:
logger.error(f"기관 순매수 조회 실패: {e}")
return []
def get_foreign_stock_trend(self, stock_code, period="D"):
"""특정 종목의 외국인 매매동향"""
try:
res = self._safe_request(
self.foreign_inst.foreign_investor_stockwise_trading_trend_request_ka10008,
stk_cd=stock_code,
period=period, # D:일, W:주, M:월
stex_tp="1"
)
return res.get('forgn_nvst_stk_trd_tnd', []) if res else []
except Exception as e:
logger.error(f"외국인 매매동향 조회 실패({stock_code}): {e}")
return []
# ==========================================================
# [퀀트 분석 메소드] - RankInfo (종목 스크리닝)
# ==========================================================
def get_volume_surge_stocks(self, market="001", min_volume="50", limit=20):
"""거래량 급증 종목"""
try:
res = self._safe_request(
self.rank.sudden_increase_trading_volume_request_ka10023,
mrkt_tp=market,
sort_tp="1", # 1:급증량, 2:급증률, 3:급감량, 4:급감률
tm_tp="2", # 1:분, 2:전일
trde_qty_tp=min_volume, # 5, 10, 50, 100, 200...
stk_cnd="1", # 관리종목제외
pric_tp="0", # 0:전체조회
stex_tp="1" # 1:KRX
)
stocks = res.get('trde_qty_sdnin', []) if res else []
return stocks[:limit]
except Exception as e:
logger.error(f"거래량 급증 조회 실패: {e}")
return []
def get_volume_surge_stocks_full_pages(
self,
market="001",
min_volume="50",
stk_cnd="1",
max_pages=5,
):
"""
거래량 급증 종목 - 한 stk_cnd에 대해 연속조회(next_key)로 전체 페이지 수집.
- next_key는 '같은 조건의 다음 페이지'만 가져오므로, 여러 stk_cnd를 쓰려면
get_volume_surge_stocks_multi_stk_cnd() 사용.
"""
collected = []
next_key = ""
page = 0
try:
while page < max_pages:
page += 1
res = self._safe_request(
self.rank.sudden_increase_trading_volume_request_ka10023,
mrkt_tp=market,
sort_tp="1",
tm_tp="2",
trde_qty_tp=min_volume,
stk_cnd=stk_cnd,
pric_tp="0",
stex_tp="1",
cont_yn="Y" if next_key else "N",
next_key=next_key,
)
if not res or "trde_qty_sdnin" not in res:
break
chunk = res.get("trde_qty_sdnin", [])
collected.extend(chunk)
next_key = res.get("next-key", "")
if not next_key:
break
time.sleep(random.uniform(1.0, 2.0))
return collected
except Exception as e:
logger.error(f"거래량 급증 연속조회 실패: {e}")
return collected
def get_volume_surge_stocks_multi_stk_cnd(
self,
stk_cnd_list=None,
market="001",
min_volume="50",
limit=100,
delay_sec=(1, 3),
):
"""
여러 stk_cnd(종목조건)로 각각 API 호출 후 결과를 append.
- next_key로는 다른 stk_cnd를 이어받을 수 없으므로, 조건마다 별도 호출 필요.
- 호출 간 딜레이로 429(횟수 제한) 완화.
- stk_cnd_list 예: ["0", "1", "4"] (전체, 관리제외, 관리+우선주제외)
"""
if stk_cnd_list is None:
stk_cnd_list = ["1"]
out = []
seen_codes = set()
for stk_cnd in stk_cnd_list:
try:
res = self._safe_request(
self.rank.sudden_increase_trading_volume_request_ka10023,
mrkt_tp=market,
sort_tp="1",
tm_tp="2",
trde_qty_tp=min_volume,
stk_cnd=stk_cnd,
pric_tp="0",
stex_tp="1",
cont_yn="N",
next_key="",
)
if res and "trde_qty_sdnin" in res:
for item in res["trde_qty_sdnin"]:
code = item.get("stk_cd", "").split("_")[0]
if code and code not in seen_codes:
seen_codes.add(code)
out.append(item)
if delay_sec and stk_cnd != stk_cnd_list[-1]:
time.sleep(random.uniform(delay_sec[0], delay_sec[1]))
except Exception as e:
logger.warning(f"거래량 급증 stk_cnd={stk_cnd} 조회 실패: {e}")
return out[:limit]
def get_top_price_movers(self, market="001", sort_type="1", limit=20):
"""등락률 상위 종목"""
try:
res = self._safe_request(
self.rank.top_day_over_day_change_rate_request_ka10027,
mrkt_tp=market,
sort_tp=sort_type, # 1:상승률, 2:상승폭, 3:하락률, 4:하락폭, 5:보합
trde_qty_cnd="0000", # 거래량조건
stk_cnd="1", # 1:관리종목제외
crd_cnd="0", # 0:전체조회
updown_incls="1", # 0:불포함, 1:포함
pric_cnd="0", # 0:전체조회
trde_prica_cnd="0", # 0:전체조회
stex_tp="1" # 1:KRX
)
stocks = res.get('pred_pre_flu_rt_upper', []) if res else []
return stocks[:limit]
except Exception as e:
logger.error(f"등락률 조회 실패: {e}")
return []
def get_top_volume_stocks(self, market="001", limit=20):
"""당일 거래량 상위 종목"""
try:
res = self._safe_request(
self.rank.top_trading_volume_today_request_ka10030,
mrkt_tp=market,
trde_qty_tp="0000",
stk_cnd="1",
stex_tp="1"
)
stocks = res.get('td_trde_qty_upper', []) if res else []
return stocks[:limit]
except Exception as e:
logger.error(f"거래량 상위 조회 실패: {e}")
return []
# ==========================================================
# [퀀트 분석 메소드] - Theme (테마 분석)
# ==========================================================
def get_hot_themes(self, limit=10):
"""급등 테마 조회 (Theme API ka90001: 테마그룹별 조회, 상위등락률)"""
try:
res = self._safe_request(
self.theme.theme_group_list_request_ka90001,
qry_tp="0", # 전체검색
date_tp="1", # 1일 전 기준
flu_pl_amt_tp="3", # 3: 상위등락률 (급등 테마)
stex_tp="1",
)
themes = res.get("thema_grp", []) if res else []
# 등락률(flu_rt) 기준 정렬 후 상위 limit개
sorted_themes = sorted(
themes,
key=lambda x: float(x.get("flu_rt", 0)),
reverse=True,
)
return sorted_themes[:limit]
except Exception as e:
logger.error(f"테마 조회 실패: {e}")
return []
# ==========================================================
# [퀀트 분석 메소드] - ETF
# ==========================================================
def get_etf_ranking(self, market="001", limit=20):
"""ETF 거래대금 상위"""
try:
res = self._safe_request(
self.etf.etf_ranking_request_ka10133,
mrkt_tp=market,
sort_tp="4", # 4:거래대금
stex_tp="1"
)
etfs = res.get('etf_rank', []) if res else []
return etfs[:limit]
except Exception as e:
logger.error(f"ETF 순위 조회 실패: {e}")
return []
# ==========================================================
# [핵심 전략 메소드] - 개미털기 & 장중 수급
# ==========================================================
def get_intraday_investor(self, code):
"""
장중 투자자별 매매 차트 (수급 확인용)
- 외국인/기관의 실시간 순매수 수량을 리턴
"""
try:
res = self._safe_request(
self.chart.intraday_investor_trading_chart_request_ka10064,
mrkt_tp="000",
amt_qty_tp="2", # 2:수량
trde_tp="0", # 0:전체
stk_cd=code
)
if not res or 'opmr_invsr_trde_chart' not in res:
return 0, 0
data_list = res['opmr_invsr_trde_chart']
if not data_list:
return 0, 0
latest = data_list[0]
foreigner = int(latest.get('frgnr_invsr', 0))
institution = int(latest.get('orgn', 0))
return foreigner, institution
except Exception as e:
logger.debug(f"장중 수급 조회 실패({code}): {e}")
return 0, 0
def scan_ant_shaking_candidates(self, max_price_limit=None):
"""
[조건검색 대체 로직] 개미털기(눌림목) 후보 종목 스캔
- 거래대금 및 회전율 상위 종목 중, 고점 대비 일정 비율 하락 후 반등 시도하는 종목 추출
📊 수집 전략:
1. 거래대금 상위 (sort_tp=3)
2. 회전율 상위 (sort_tp=2)
→ 합친 후 중복 제거
💎 강도 계산:
- drop_rate = (시가 - 저가) / 시가 (낙폭 %)
- recovery = (현재가 - 저가) / (고가 - 저가) (회복률)
- 조건: 낙폭 3% 이상 & 회복 50% 이상
- 점수 = drop_rate * 100 (낙폭이 클수록 강함!)
"""
logger.info(f"🐜 [개미털기] 스캔 시작")
logger.info(f" 📡 수집 방식: 거래대금 + 회전율 (2가지 소스)")
raw_codes_set = set()
scan_strategies = [("3", "거래대금"), ("2", "회전율")]
for sort_tp, desc in scan_strategies:
logger.info(f" 🔍 [{desc}] 상위 종목 조회 중...")
try:
res = self._safe_request(
self.rank.top_trading_volume_today_request_ka10030,
mrkt_tp="000",
sort_tp=sort_tp,
mang_stk_incls="3",
pric_tp="0",
crd_tp="0",
trde_qty_tp="0",
trde_prica_tp="0",
mrkt_open_tp="0",
stex_tp="3",
cont_yn="N"
)
if not res or 'tdy_trde_qty_upper' not in res:
logger.warning(f" ⚠️ [{desc}] 응답 없음")
continue
logger.info(f" ✅ [{desc}] {len(res['tdy_trde_qty_upper'])}개 수신")
for stock in res['tdy_trde_qty_upper']:
code = stock['stk_cd'].split('_')[0]
try:
price = abs(float(stock['cur_prc']))
except:
continue
open_price = abs(float(stock.get('open_pric', price)))
change_rate = ((price - open_price) / open_price * 100) if open_price > 0 else 0
if price < 1000: # 동전주 제외
continue
if change_rate > 20: # 상한가 근처 제외
logger.info(f"🚫 상한가 제외: {stock['stk_nm']} (+{change_rate:.1f}%)")
continue
if price > 200000: # 20만원 이상 제외
continue
# 종목 필터링
name = stock['stk_nm']
if self._is_valid_stock(name, code):
raw_codes_set.add(code)
except Exception as e:
logger.error(f"스캔({desc}) 중 에러: {e}")
raw_codes = list(raw_codes_set)
logger.info(f" 📥 후보 수집: {len(raw_codes)}개 -> 정밀 분석")
final_list = []
chunk_size = 50
try:
for i in range(0, len(raw_codes), chunk_size):
chunk = raw_codes[i:i + chunk_size]
code_str = '|'.join(chunk)
# 다중 종목 현재가 조회
res = self._safe_request(
self.stock_info.watchlist_stock_information_request_ka10095,
stock_code=code_str
)
if res and 'atn_stk_infr' in res:
for item in res['atn_stk_infr']:
code = item['stk_cd'].strip()
if code.startswith('A'):
code = code[1:]
try:
op = abs(float(item.get('open_pric', 0)))
hi = abs(float(item.get('high_pric', 0)))
lo = abs(float(item.get('low_pric', 0)))
cl = abs(float(item.get('cur_prc', 0)))
if op == 0:
continue
if max_price_limit and cl > max_price_limit:
continue
# 낙폭 계산
drop_rate = (op - lo) / op
total_range = hi - lo
recovery_pos = (cl - lo) / total_range if total_range > 0 else 0
# 낙폭이 3% 이상, 아래꼬리를 달고 올라온 종목
if total_range > 0 and drop_rate > 0.03:
if recovery_pos > 0.5:
score = drop_rate * 100
logger.info(
f" 💎 {item['stk_nm'].strip()} {code}: "
f"낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 점수 {score:.2f}"
)
final_list.append({
'code': code,
'name': item['stk_nm'].strip(),
'price': cl,
'score': score
})
except Exception as e:
logger.debug(f"종목 분석 오류({code}): {e}")
continue
time.sleep(0.5)
final_list.sort(key=lambda x: x['score'], reverse=True)
return final_list
except Exception as e:
logger.error(f"분석 중 치명적 에러: {e}")
return []
def _is_valid_stock(self, name, code):
"""종목 필터링 (스팩, ETN, 우선주 등 제외)"""
if len(code) != 6 or not code.isdigit():
return False
exclude = ['스팩', 'ETN', 'W', 'ELW', '채권', '레버리지', '인버스',
'곱버스', '선물', '', '', '2X', '3X', '합성', 'H', 'B']
if any(k in name for k in exclude):
return False
if name.endswith('') or name.endswith('우B'):
return False
return True
# ==========================================================
# [Part 2] 메인 트레이딩 봇 Ver2
# ==========================================================
class TradingBotV2:
def __init__(self, broker_api):
self.api = broker_api
# Mattermost 초기화
self.mm = MattermostBot()
self.mm_channel = "stock"
# DB 초기화
self.db = TradeDB(db_path="quant_bot.db")
# Risk Manager 초기화
kelly_enabled = os.environ.get("USE_KELLY", "false").lower() == "true"
self.risk_mgr = RiskManager(
risk_pct_per_trade=get_env_float("RISK_PCT_PER_TRADE", "0.02"),
max_position_pct=get_env_float("MAX_POSITION_PCT", "0.20"),
min_position_amount=get_env_int("MIN_POSITION_AMOUNT", "50000"),
use_kelly=kelly_enabled
)
# Smart Executor 초기화 (TWAP 분할 매수)
use_twap = os.environ.get("USE_TWAP", "false").lower() == "true"
self.use_twap = use_twap
if use_twap:
self.executor = SmartOrderExecutor(
min_split_amount=get_env_int("TWAP_MIN_SPLIT", "500000"),
max_split_amount=get_env_int("TWAP_MAX_SPLIT", "2000000"),
min_delay_seconds=get_env_int("TWAP_MIN_DELAY", "30"),
max_delay_seconds=get_env_int("TWAP_MAX_DELAY", "180")
)
else:
self.executor = None
# ML Predictor 초기화
self.use_ml = os.environ.get("USE_ML_SIGNAL", "false").lower() == "true"
self.ml_min_prob = get_env_float("ML_MIN_PROBABILITY", "0.65")
if self.use_ml:
try:
self.ml_predictor = MLPredictor(db_path="quant_bot.db")
if self.ml_predictor.should_retrain():
logger.info("🤖 ML 모델 학습 시작...")
self.ml_predictor.train_model(retrain=True)
except Exception as e:
logger.warning(f"⚠️ ML 초기화 실패: {e}")
self.ml_predictor = None
else:
self.ml_predictor = None
# News Analyzer 초기화
self.use_news = os.environ.get("USE_NEWS_ANALYSIS", "false").lower() == "true"
self.news_hour = get_env_int("NEWS_ANALYSIS_HOUR", "9")
self.news_max = get_env_int("NEWS_MAX_COUNT", "5")
self.news_analyzed_today = False
if self.use_news:
try:
self.news_analyzer = NewsAnalyzer()
except Exception as e:
logger.warning(f"⚠️ News 초기화 실패: {e}")
self.news_analyzer = None
else:
self.news_analyzer = None
# 구글 트렌드 괴리율 분석기 초기화
try:
self.trend_analyzer = TrendDivergenceAnalyzer()
logger.info("✅ 구글 트렌드 괴리율 분석기 초기화 완료")
except Exception as e:
logger.warning(f"⚠️ 트렌드 분석기 초기화 실패: {e}")
self.trend_analyzer = None
# 관세청 수출 호재 감지기 초기화
try:
self.export_sniper = ExportSniper()
logger.info("✅ 관세청 수출 호재 감지기 초기화 완료")
except Exception as e:
logger.warning(f"⚠️ 수출 감지기 초기화 실패: {e}")
self.export_sniper = None
# 뉴스 감시 스레드 상태
self.news_monitor_running = False
self._news_monitor_task = None # asyncio.Task (스레드 제거)
# 거래 설정
self.max_stocks = get_env_int("MAX_STOCKS", "5")
self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", "-0.035")
# 🚨 리스크 관리 (손실 한도)
self.use_risk_check = os.environ.get("USE_RISK_CHECK", "true").lower() == "true"
self.daily_stop_loss_pct = get_env_float("DAILY_STOP_LOSS_PCT", "-0.05")
self.consecutive_loss_limit = get_env_int("CONSECUTIVE_LOSS_LIMIT", "4")
self.max_loss_per_trade_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", "200000")
# 🚫 금지 종목 관리 (재매수 방지)
self.use_ban_system = os.environ.get("USE_BAN_SYSTEM", "true").lower() == "true"
self.ban_hours = get_env_int("BAN_HOURS", "24")
# 🗑️ 종목 필터링 (쓰레기 종목 제외)
self.use_stock_filter = os.environ.get("USE_STOCK_FILTER", "true").lower() == "true"
# 전략 파라미터
self.rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", "73")
self.shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", "0.03")
self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO", "0.5")
self.max_recovery_ratio = get_env_float("MAX_RECOVERY_RATIO", "0.8")
# POP/LOCK 순수익 기반 익절 파라미터 (소수 단위)
# ROUND_TRIP_COST_PCT: 수수료+세금 왕복 비용 비율 (예: 0.0026 = 0.26%)
# POP_NET_PCT: POP 판단용 순수익 기준 (예: 0.05 = +5% 순수익 이상 갔던 경우만)
# LOCK_NET_PCT: 되돌림 시 지킬 최소 순수익 (예: 0.003 = +0.3% 순수익)
self.round_trip_cost_pct = get_env_float("ROUND_TRIP_COST_PCT", "0.0026")
self.pop_net_pct = get_env_float("POP_NET_PCT", "0.05")
self.lock_net_pct = get_env_float("LOCK_NET_PCT", "0.003")
# 상태 변수
self.current_cash = 0
self.start_asset = 0
self.current_total_asset = 0
self.prev_session_asset = 0 # 이전 세션 자산
self.start_day_asset = 0 # 당일 시작 자산
self.today_date = datetime.datetime.now().strftime("%Y%m%d")
self.trading_halted = False
# 종목별 손익 경로 상태 (순수익 POP/LOCK 판단용, 프로세스 생명주기 내에서만 유지)
# { code: {"max_profit_pct": float, "min_profit_pct": float, "went_negative": bool} }
self.trade_state = {}
# 분할 매수 진행 중인 종목 추적 (중복 실행 방지) — asyncio.Task 사용 (스레드 제거)
# { code: asyncio.Task }. split_buy_lock은 _run_async() 시작 시 생성 (이벤트 루프 필요)
self.active_split_buys = {}
self.split_buy_lock = None
# 장 상태 관리
self.was_market_open = False
self.is_first_run = True
self.trading_halted = False # 일일 손실 한도 or 연속 손절로 거래 중단 여부
# 디버그용: 장 상태 강제 오픈 (비장시간 테스트용)
# FORCE_MARKET_OPEN=true 이면 check_market_status 결과를 무시하고 항상 장이 열린 것으로 간주
self.force_market_open = os.environ.get("FORCE_MARKET_OPEN", "false").lower() == "true"
# 계좌 조회 실패 카운트 초기화 (안전성을 위해 여기서도 초기화)
if not hasattr(self, 'account_query_fail_count'):
self.account_query_fail_count = 0
if not hasattr(self, 'max_account_fail_alert'):
self.max_account_fail_alert = get_env_int("MAX_ACCOUNT_FAIL_ALERT", "3")
def get_env_snapshot(self):
"""매도 시점 env 스냅샷 (trade_history INSERT / env_config / 백테스트·대시보드용). 비밀/토큰 제외."""
return {
# 손절·목표
"STOP_LOSS_PCT": os.environ.get("STOP_LOSS_PCT", "-0.035"),
"SHOULDER_CUT_PCT": os.environ.get("SHOULDER_CUT_PCT", "0.03"),
"STOP_ATR_MULTIPLIER_TAIL": os.environ.get("STOP_ATR_MULTIPLIER_TAIL", "3.5"),
"TARGET_ATR_MULTIPLIER_TAIL": os.environ.get("TARGET_ATR_MULTIPLIER_TAIL", "8.0"),
# 포지션·슬롯
"MAX_POSITION_PCT": os.environ.get("MAX_POSITION_PCT", "0.20"),
"USE_SLOT_CAP": os.environ.get("USE_SLOT_CAP", "true"),
"SLOT_CAP_PCT": os.environ.get("SLOT_CAP_PCT", "0.9"),
"MAX_STOCKS": os.environ.get("MAX_STOCKS", "5"),
# 켈리·리스크
"USE_KELLY": os.environ.get("USE_KELLY", "false"),
"RISK_PCT_PER_TRADE": os.environ.get("RISK_PCT_PER_TRADE", "0.02"),
"MIN_POSITION_AMOUNT": os.environ.get("MIN_POSITION_AMOUNT", "50000"),
# 리스크 한도
"USE_RISK_CHECK": os.environ.get("USE_RISK_CHECK", "true"),
"DAILY_STOP_LOSS_PCT": os.environ.get("DAILY_STOP_LOSS_PCT", "-0.05"),
"CONSECUTIVE_LOSS_LIMIT": os.environ.get("CONSECUTIVE_LOSS_LIMIT", "4"),
"MAX_LOSS_PER_TRADE_KRW": os.environ.get("MAX_LOSS_PER_TRADE_KRW", "200000"),
# 금지 종목
"USE_BAN_SYSTEM": os.environ.get("USE_BAN_SYSTEM", "true"),
"BAN_HOURS": os.environ.get("BAN_HOURS", "24"),
# 종목 필터·전략
"USE_STOCK_FILTER": os.environ.get("USE_STOCK_FILTER", "true"),
"RSI_OVERHEAT_THRESHOLD": os.environ.get("RSI_OVERHEAT_THRESHOLD", "73"),
"MIN_RECOVERY_RATIO": os.environ.get("MIN_RECOVERY_RATIO", "0.5"),
"MAX_RECOVERY_RATIO": os.environ.get("MAX_RECOVERY_RATIO", "0.8"),
# TWAP
"USE_TWAP": os.environ.get("USE_TWAP", "false"),
"TWAP_MIN_SPLIT": os.environ.get("TWAP_MIN_SPLIT", "500000"),
"TWAP_MAX_SPLIT": os.environ.get("TWAP_MAX_SPLIT", "2000000"),
"TWAP_MIN_DELAY": os.environ.get("TWAP_MIN_DELAY", "30"),
"TWAP_MAX_DELAY": os.environ.get("TWAP_MAX_DELAY", "180"),
# ML·뉴스
"USE_ML_SIGNAL": os.environ.get("USE_ML_SIGNAL", "false"),
"ML_MIN_PROBABILITY": os.environ.get("ML_MIN_PROBABILITY", "0.65"),
"USE_NEWS_ANALYSIS": os.environ.get("USE_NEWS_ANALYSIS", "false"),
"NEWS_ANALYSIS_HOUR": os.environ.get("NEWS_ANALYSIS_HOUR", "9"),
"NEWS_MAX_COUNT": os.environ.get("NEWS_MAX_COUNT", "5"),
# 스캔·필터
"USE_QUICK_PROFIT_PROTECTION": os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true"),
"HIGH_PRICE_CHASE_THRESHOLD": os.environ.get("HIGH_PRICE_CHASE_THRESHOLD", "0.96"),
"MAX_DAILY_CHANGE_PCT": os.environ.get("MAX_DAILY_CHANGE_PCT", "20.0"),
"MA20_MAX_ABOVE_PCT": os.environ.get("MA20_MAX_ABOVE_PCT", "3.0"),
"VOLUME_AVG_MULTIPLIER": os.environ.get("VOLUME_AVG_MULTIPLIER", "1.0"),
"CANDLE_OPEN_PRICE_BUFFER": os.environ.get("CANDLE_OPEN_PRICE_BUFFER", "0.995"),
"INTRADAY_INVESTOR_NET_BUY_THRESHOLD": os.environ.get("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000"),
# 대중소형
"SIZE_CLASS_LARGE_MIN": os.environ.get("SIZE_CLASS_LARGE_MIN", "5000000000"),
"SIZE_CLASS_MID_MIN": os.environ.get("SIZE_CLASS_MID_MIN", "500000000"),
# 기타
"USE_RANDOM_SPLIT": os.environ.get("USE_RANDOM_SPLIT", "true"),
"FORCE_MARKET_OPEN": os.environ.get("FORCE_MARKET_OPEN", "false"),
"TOTAL_DEPOSIT": os.environ.get("TOTAL_DEPOSIT", "0"),
}
# 리포트 플래그
self.morning_report_sent = False
self.closing_report_sent = False
self.final_report_sent = False
self.news_analyzed_today = False
# 계좌 조회 실패 추적
self.account_query_fail_count = 0
self.max_account_fail_alert = 3 # 3회 연속 실패 시 알림
# 총 입금액 (누적 손익률 계산용)
self.total_deposit = get_env_float("TOTAL_DEPOSIT", "0")
# 봇 상태 파일
self.bot_state_file = os.path.join(current_dir, 'bot_state.json')
# 이전 세션 상태 로드
self._load_bot_state()
# 초기 계좌 정보 로드
self.refresh_account()
# JSON 마이그레이션 (최초 1회)
self._migrate_from_json_if_needed()
# 관리자 웹에서 저장한 최신 설정을 DB에서 불러와 런타임에 적용
# (UPDATE/스냅샷 찍기 아님: "DB 최신값 -> 봇 설정" 방향)
try:
latest = self.db.get_latest_env()
if latest and isinstance(latest.get("snapshot"), dict) and latest["snapshot"]:
applied = 0
for k, v in latest["snapshot"].items():
# os.environ은 str만 저장 가능
os.environ[str(k)] = "" if v is None else str(v)
applied += 1
logger.info(
f"✅ DB env_config 최신값 적용: id={latest.get('id')} keys={applied}"
)
else:
logger.info(" DB env_config 최신값 없음: .env/os.environ 사용")
except Exception as e:
logger.warning(f"⚠️ DB env_config 적용 실패: {e} (계속 .env/os.environ 사용)")
# 시작 메시지
self._send_startup_message(kelly_enabled, use_twap)
def _load_bot_state(self):
"""이전 실행 시 봇 상태 로드"""
try:
if os.path.exists(self.bot_state_file):
with open(self.bot_state_file, 'r', encoding='utf-8') as f:
state = json.load(f)
self.prev_session_asset = float(state.get('start_equity', 0))
prev_day = state.get('start_day', '')
if prev_day != self.today_date:
# 새로운 날
logger.info(f"📅 새로운 거래일: {self.today_date}")
self.start_day_asset = 0 # 나중에 refresh_account에서 설정
else:
# 같은 날 재시작
self.start_day_asset = float(state.get('start_day_asset', 0))
logger.info(f"🔄 봇 재시작 (당일 시작: {self.start_day_asset:,.0f}원)")
else:
logger.info("📝 최초 실행 - 새로운 bot_state.json 생성")
except Exception as e:
logger.error(f"⚠️ 봇 상태 로드 실패: {e}")
def _save_bot_state(self):
"""현재 봇 상태 저장"""
try:
state = {
'start_equity': self.current_total_asset,
'start_day': self.today_date,
'start_day_asset': self.start_day_asset,
'last_update': datetime.datetime.now().isoformat()
}
with open(self.bot_state_file, 'w', encoding='utf-8') as f:
json.dump(state, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"⚠️ 봇 상태 저장 실패: {e}")
def _send_startup_message(self, kelly_enabled, use_twap):
"""봇 시작 메시지 전송"""
# 세션 대비 손익률
session_pnl_pct = 0.0
if self.prev_session_asset > 0:
session_pnl_pct = ((self.current_total_asset - self.prev_session_asset) / self.prev_session_asset) * 100
# 당일 손익률
day_pnl_pct = 0.0
if self.start_day_asset > 0:
day_pnl_pct = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset) * 100
# 누적 손익률 (총 입금액 대비)
cumulative_pnl_pct = 0.0
if self.total_deposit > 0:
cumulative_pnl_pct = ((self.current_total_asset - self.total_deposit) / self.total_deposit) * 100
# 계좌 정보가 0원으로 떨어진 경우 보호 로직
# (예: API 장애 / 로그인 실패 등으로 예수금·자산을 못 가져온 경우)
if self.current_total_asset <= 0:
logger.warning(
"⚠️ 계좌 자산이 0원으로 조회되었습니다. "
"API 오류 / 네트워크 문제 가능성이 있어 시작 손익률을 0%로 표시합니다."
)
session_pnl_pct = 0.0
day_pnl_pct = 0.0
cumulative_pnl_pct = 0.0
# 🚨 피뢰침 방지 필터 정보
use_quick_profit = os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true").lower() == "true"
high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", "0.96")
max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", "20.0")
msg = f"""🤖 **[트레이딩봇 Ver2 가동]**
- 현재 자산: {self.current_total_asset:,.0f}
- 세션 손익: {session_pnl_pct:+.2f}%
- 당일 손익: {day_pnl_pct:+.2f}%
- 누적 손익: {cumulative_pnl_pct:+.2f}% (입금액: {self.total_deposit:,.0f}원)
- DB 기반 안전 관리
- 변동성 기반 자금 관리
- TWAP: {'ON' if use_twap else 'OFF'}
- 켈리 공식: {'ON' if kelly_enabled else 'OFF'}
- ML 필터: {'ON' if getattr(self, 'use_ml', False) else 'OFF'} (임계값: {getattr(self, 'ml_min_prob', 0.0):.2f})
- 뉴스 분석: {'ON' if getattr(self, 'use_news', False) else 'OFF'}
- 🛑 리스크 체크: {'ON' if self.use_risk_check else 'OFF'} (일일: {self.daily_stop_loss_pct*100:.1f}%, 연속: {self.consecutive_loss_limit}회)
- 🚫 금지 종목: {'ON' if self.use_ban_system else 'OFF'} ({self.ban_hours}시간)
- 🗑️ 종목 필터: {'ON' if self.use_stock_filter else 'OFF'}
- 💨 작은수익보호: {'ON' if use_quick_profit else 'OFF'} (30분 내)
- ⚡ 피뢰침 방지: ON (고점 {(1-high_chase_threshold)*100:.0f}%+ 조정 | 급등 {max_daily_change:.0f}% 제외)"""
logger.info(msg)
self.send_mm(msg)
def _migrate_from_json_if_needed(self):
"""기존 JSON 파일에서 DB로 마이그레이션 (1회성)"""
portfolio_file = os.path.join(current_dir, 'portfolio.json')
if os.path.exists(portfolio_file):
try:
with open(portfolio_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if data:
count = self.db.migrate_from_json(data)
logger.info(f"📦 JSON 마이그레이션 완료: {count}개 종목")
# 백업 후 삭제
backup_path = portfolio_file + ".backup"
os.rename(portfolio_file, backup_path)
logger.info(f"💾 기존 JSON 백업: {backup_path}")
except Exception as e:
logger.error(f"❌ JSON 마이그레이션 실패: {e}")
def send_mm(self, msg):
"""Mattermost 알림 전송"""
try:
self.mm.send(self.mm_channel, msg)
except Exception as e:
logger.error(f"❌ MM 전송 에러: {e}")
def send_morning_report(self):
"""오전 장 뜸할 때 리포트 (13:00)"""
if self.morning_report_sent:
return
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.db.get_active_trades())}"""
self.send_mm(msg)
self.morning_report_sent = True
logger.info("📊 오전 리포트 전송 완료")
def send_closing_report(self):
"""장마감 전 리포트 (15:15)"""
if self.closing_report_sent:
return
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.db.get_active_trades())}
- 예수금: {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
# 당일 손익
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.db.get_active_trades())}
- 예수금: {self.current_cash:,.0f}
━━━━━━━━━━━━━━━━━━━━"""
self.send_mm(msg)
self.final_report_sent = True
logger.info("🏁 장마감 최종 리포트 전송 완료")
def analyze_and_send_news(self):
"""뉴스 AI 분석 및 Mattermost 알림"""
if self.news_analyzed_today:
return
logger.info("📰 [뉴스 분석] 시작")
try:
news_list = self.news_analyzer.crawl_naver_finance_news(max_news=self.news_max)
if not news_list:
logger.warning("⚠️ 크롤링된 뉴스 없음")
self.news_analyzed_today = True
return
analysis = self.news_analyzer.analyze_news_with_claude(news_list)
if not analysis:
logger.warning("⚠️ AI 분석 실패")
self.news_analyzed_today = True
return
message = self.news_analyzer.format_analysis_for_mattermost(analysis, news_list)
if message:
self.send_mm(message)
logger.info("✅ 뉴스 분석 알림 전송 완료")
self.news_analyzed_today = True
except Exception as e:
logger.error(f"❌ 뉴스 분석 중 에러: {e}")
self.news_analyzed_today = True
def refresh_account(self):
"""계좌 정보 갱신 (전체 조회 - 5분마다)"""
# 안전성: 속성이 없으면 즉시 초기화 (초기화 순서 문제 방지)
if not hasattr(self, 'account_query_fail_count'):
self.account_query_fail_count = 0
if not hasattr(self, 'max_account_fail_alert'):
self.max_account_fail_alert = get_env_int("MAX_ACCOUNT_FAIL_ALERT", "3")
try:
info = self.api.get_account_info()
# API 응답 검증
if not info:
if not hasattr(self, 'account_query_fail_count'):
self.account_query_fail_count = 0
self.account_query_fail_count += 1
logger.error(f"❌ 계좌 조회 실패 ({self.account_query_fail_count}회 연속) - API 응답 없음 | 이전 값 유지: {self.current_total_asset:,.0f}")
# 3회 연속 실패 시 알림
if self.account_query_fail_count >= self.max_account_fail_alert:
try:
self.send_mm(
f"🚨 **[계좌 조회 실패 경고]**\n"
f"- 연속 실패: {self.account_query_fail_count}\n"
f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}\n"
f"- API 상태를 확인하세요!"
)
except:
pass
return
# 0원 응답 검증 (초기화 제외)
if info.get('total_asset', 0) <= 0 and self.current_total_asset > 0:
if not hasattr(self, 'account_query_fail_count'):
self.account_query_fail_count = 0
self.account_query_fail_count += 1
logger.error(
f"❌ 계좌 조회 이상 ({self.account_query_fail_count}회 연속) - "
f"응답: 예수금={info.get('deposit', 0):,.0f}원, 총자산={info.get('total_asset', 0):,.0f}원 | "
f"이전 값 유지: {self.current_total_asset:,.0f}"
)
# 3회 연속 실패 시 알림
if self.account_query_fail_count >= self.max_account_fail_alert:
try:
self.send_mm(
f"🚨 **[계좌 조회 이상 경고]**\n"
f"- 연속 실패: {self.account_query_fail_count}\n"
f"- API 응답: 0원 (비정상)\n"
f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}\n"
f"- 토큰 만료 or API 장애 가능성"
)
except:
pass
return
# ✅ 정상 응답 - 값 업데이트
self.current_cash = info['deposit']
self.current_total_asset = info['total_asset']
# 성공 시 실패 카운트 리셋
if hasattr(self, 'account_query_fail_count') and self.account_query_fail_count > 0:
logger.info(f"✅ 계좌 조회 복구 (이전 {self.account_query_fail_count}회 실패)")
self.account_query_fail_count = 0
# 첫 실행 시 시작 자산 설정
if self.start_asset == 0:
self.start_asset = info['total_asset']
# 당일 시작 자산 설정 (최초 1회)
if self.start_day_asset == 0:
self.start_day_asset = info['total_asset']
# 상태 저장
self._save_bot_state()
logger.info(
f"💰 예수금: {self.current_cash:,.0f}원 | "
f"총자산: {info['total_asset']:,.0f}"
f"(예수금 {self.current_cash:,.0f} + 주식 {info['stock_value']:,.0f})"
)
except Exception as e:
if not hasattr(self, 'account_query_fail_count'):
self.account_query_fail_count = 0
self.account_query_fail_count += 1
logger.error(f"❌ 계좌 정보 갱신 예외 ({self.account_query_fail_count}회 연속): {e} | 이전 값 유지")
# 3회 연속 실패 시 알림
if self.account_query_fail_count >= self.max_account_fail_alert:
try:
self.send_mm(
f"🚨 **[계좌 조회 예외 경고]**\n"
f"- 연속 실패: {self.account_query_fail_count}\n"
f"- 에러: {str(e)[:100]}\n"
f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}"
)
except:
pass
def get_banned_codes(self):
"""금지 종목 리스트 반환 (손절 후 재매수 방지)"""
# 🚫 금지 시스템이 꺼져있으면 빈 리스트 반환
if not self.use_ban_system:
return []
try:
banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json')
if not os.path.exists(banned_file):
return []
with open(banned_file, 'r', encoding='utf-8') as f:
data = json.load(f)
active = []
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
for code, expire_time in data.items():
if expire_time > now:
active.append(code)
return active
except Exception as e:
logger.error(f"❌ 금지 종목 조회 실패: {e}")
return []
def add_ban(self, code, name):
"""종목을 금지 리스트에 추가 (손절 후 재매수 방지)"""
# 🚫 금지 시스템이 꺼져있으면 아무것도 안 함
if not self.use_ban_system:
return
try:
banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json')
# 기존 데이터 로드
if os.path.exists(banned_file):
with open(banned_file, 'r', encoding='utf-8') as f:
data = json.load(f)
else:
data = {}
# 만료 시각 설정 (self.ban_hours 사용!)
expire_time = (datetime.datetime.now() + datetime.timedelta(hours=self.ban_hours)).strftime('%Y-%m-%d %H:%M:%S')
data[code] = expire_time
# 저장
with open(banned_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.warning(f"🚫 [매수 금지 추가] {name} ({code}): {self.ban_hours}시간 동안 재매수 불가")
except Exception as e:
logger.error(f"❌ 금지 종목 추가 실패: {e}")
def cleanup_banned_list(self):
"""만료된 금지 종목 정리"""
try:
banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json')
if not os.path.exists(banned_file):
return
with open(banned_file, 'r', encoding='utf-8') as f:
data = json.load(f)
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
new_data = {k: v for k, v in data.items() if v > now}
if len(data) != len(new_data):
with open(banned_file, 'w', encoding='utf-8') as f:
json.dump(new_data, f, ensure_ascii=False, indent=2)
logger.info(f"🧹 [금지 종목 정리] {len(data) - len(new_data)}개 만료 제거")
except Exception as e:
logger.error(f"❌ 금지 종목 정리 실패: {e}")
def _is_valid_stock(self, name, code):
"""
종목 필터링 (Dual 로직 이식!)
- 스팩, ETN, 우선주, 레버리지, 인버스 등 제외
- "쓰레기 같은걸 자꾸 사는" 문제 방지!
"""
# 🗑️ 필터링이 꺼져있으면 모두 통과
if not self.use_stock_filter:
return True
try:
# 1. 코드 유효성 검사
if len(code) != 6 or not code.isdigit():
return False
# 2. 제외 키워드 검사
exclude_keywords = [
'스팩', 'SPAC', 'ETN', 'W', 'ELW', '채권',
'레버리지', '인버스', '곱버스',
'선물', '', '',
'2X', '3X', '합성', 'H', 'B'
]
for keyword in exclude_keywords:
if keyword in name:
logger.info(f"🔍 [Pass-필터] {name} ({code}): '{keyword}' 포함 → 제외")
return False
# 3. 우선주 제외
if name.endswith('') or name.endswith('우B'):
logger.info(f"🔍 [Pass-필터] {name} ({code}): 우선주 → 제외")
return False
return True
except Exception as e:
logger.error(f"❌ 종목 필터링 실패: {e}")
return True # 예외 시 통과 (보수적)
def check_risk_status(self):
"""
일일 손실 한도 체크 (Dual 로직 이식!)
- 하루 손실 한도 도달 시 거래 중단
- 연속 손절 한도 도달 시 거래 중단
"""
# 🛑 리스크 체크가 꺼져있으면 아무것도 안 함
if not self.use_risk_check:
return False
try:
# 1. 일일 수익률 계산
if self.start_day_asset > 0:
daily_profit_pct = (self.current_total_asset - self.start_day_asset) / self.start_day_asset
else:
daily_profit_pct = 0
# 2. 일일 손실 한도 체크 (self.daily_stop_loss_pct 사용!)
if daily_profit_pct <= self.daily_stop_loss_pct and not self.trading_halted:
self.trading_halted = True
msg = (
f"🛑 **[STOP LOSS 발동]**\n"
f"일일 손실률: {daily_profit_pct * 100:.2f}%\n"
f"기준: -5.0%\n"
f"→ 금일 매수 중단!"
)
logger.critical(msg)
self.send_mm(msg)
return True
# 3. 연속 손절 체크 (self.consecutive_loss_limit 사용!)
# DB에서 최근 N개 거래 조회
recent_trades = self.db.conn.execute(f"""
SELECT sell_reason, profit_rate
FROM trade_history
ORDER BY id DESC
LIMIT {self.consecutive_loss_limit}
""").fetchall()
if len(recent_trades) >= self.consecutive_loss_limit:
# 모두 손실이고 손절 사유인지 확인
all_stop_loss = all(
reason and ("손절" in reason or "어깨" in reason) and profit < 0
for reason, profit in recent_trades
)
if all_stop_loss and not self.trading_halted:
self.trading_halted = True
msg = (
f"🛑 **[연속 손절 과다]**\n"
f"최근 {self.consecutive_loss_limit}회 연속 손절 발생\n"
f"→ 금일 매수 중단!"
)
logger.critical(msg)
self.send_mm(msg)
return True
return False
except Exception as e:
logger.error(f"❌ 리스크 상태 체크 실패: {e}")
return False
def sync_portfolio_from_api(self):
"""
실제 계좌 보유 종목 ↔ DB 동기화 (Dual 로직 이식!)
- 실제 계좌에는 있지만 DB에 없는 종목 → RECOVERED 전략으로 추가
- DB에는 있지만 실제 계좌에 없는 종목 → DB에서 제거
"""
try:
logger.info("🔄 [실계좌↔DB 동기화 시작]")
# 1. 실제 계좌 보유 종목 조회
info = self.api.get_account_info()
if not info or not info.get('holdings'):
logger.warning("⚠️ 실제 계좌 보유 종목 조회 실패")
return
real_holdings = info['holdings'] # {code: {name, qty, buy_price, current_price, ...}}
db_trades = self.db.get_active_trades()
real_codes = set(real_holdings.keys())
db_codes = set(db_trades.keys())
# 2. 실제에는 있지만 DB에 없는 종목 (RECOVERED 전략으로 추가)
missing_in_db = real_codes - db_codes
if missing_in_db:
logger.warning(f"⚠️ DB에 없는 종목 {len(missing_in_db)}개 발견!")
for code in missing_in_db:
stock = real_holdings[code]
name = stock['name']
qty = stock['qty']
buy_price = stock['buy_price']
current_price = stock['current_price']
# ATR 복구 시도
atr = buy_price * 0.01 # 기본값
try:
df = self.api.get_ohlcv_limit(code, timeframe='1m', limit=100)
if df is not None and not df.empty:
atr = self.calculate_atr(df)
except Exception as e:
logger.error(f"❌ [{name}] ATR 계산 실패: {e}")
# DB에 추가
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
trade_data = {
'code': code,
'name': name,
'avg_buy_price': buy_price,
'stop_price': buy_price - (atr * 3.0),
'target_price': buy_price + (atr * 5.0),
'atr_entry': atr,
'target_qty': qty,
'current_qty': qty,
'total_invested': buy_price * qty,
'status': 'HOLDING',
'strategy': 'RECOVERED',
'buy_date': now_str,
'max_price': current_price
}
self.db.upsert_trade(trade_data)
logger.info(f"♻️ [RECOVERED] {name} ({code}): {qty}주 @ {buy_price:,.0f}원 → DB 추가")
# Mattermost 알림
self.send_mm(
f"♻️ **[DB 복구]** {name}\n"
f"수량: {qty}주 @ {buy_price:,.0f}\n"
f"전략: RECOVERED (실제 계좌에서 발견)"
)
# 3. DB에는 있지만 실제에는 없는 종목 (자동 매도된 종목, DB 정리)
missing_in_real = db_codes - real_codes
if missing_in_real:
logger.warning(f"⚠️ 실제 계좌에 없는 종목 {len(missing_in_real)}개 발견!")
for code in missing_in_real:
trade = db_trades[code]
name = trade['name']
buy_price = trade['avg_buy_price']
# 현재가 조회 (최종 매도가 추정)
current_price = self.api.get_current_price(code)
sell_price = current_price if current_price else buy_price
# DB에서 제거 (강제 매도 처리)
env_snapshot = json.dumps(self.get_env_snapshot(), ensure_ascii=False)
self.db.close_trade(code, sell_price, "동기화_정리(실제계좌없음)", env_snapshot=env_snapshot, size_class=trade.get('size_class'))
logger.info(f"🗑️ [DB 정리] {name} ({code}): 실제 계좌에 없음 → DB 제거")
# Mattermost 알림
self.send_mm(
f"🗑️ **[DB 정리]** {name}\n"
f"사유: 실제 계좌에 없음\n"
f"추정 매도가: {sell_price:,.0f}"
)
# 4. 공통 종목의 수량/평단 동기화 (★ 체결 기준으로 정합성 맞추기)
common_codes = real_codes & db_codes
for code in common_codes:
real = real_holdings[code]
trade = db_trades[code]
real_qty = real['qty']
db_qty = trade['current_qty']
real_buy = float(real.get('buy_price', 0.0))
db_buy = float(trade.get('avg_buy_price') or trade.get('buy_price') or 0.0)
real_cur = float(real.get('current_price', 0.0))
qty_mismatch = real_qty != db_qty
buy_mismatch = abs(real_buy - db_buy) > 1e-4
if qty_mismatch or buy_mismatch:
name = trade['name']
logger.warning(
f"⚠️ [{name}] 포지션 불일치: "
f"수량 DB={db_qty} vs 실계좌={real_qty}, "
f"평단 DB={db_buy:.2f} vs 실계좌={real_buy:.2f}"
)
# --- STOP/TARGET 재계산 (실제 평단 기준) ---
old_stop = float(trade.get('stop_price') or 0.0)
old_target = float(trade.get('target_price') or 0.0)
atr = float(trade.get('atr_at_entry') or trade.get('atr_entry') or 0.0)
new_stop = old_stop
new_target = old_target
if atr > 0:
# env에서 ATR 배수 가져와서 새 평단 기준으로 재계산
atr_multiplier_stop = get_env_float("STOP_ATR_MULTIPLIER_TAIL", "3.5")
atr_multiplier_target = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", "8.0")
new_stop = real_buy - (atr * atr_multiplier_stop)
new_target = real_buy + (atr * atr_multiplier_target)
else:
# ATR이 없으면 기존 거리(평단~STOP/TARGET)를 유지한 채 평단만 옮김
if old_stop > 0 and old_target > 0 and db_buy > 0:
stop_delta = db_buy - old_stop # 평단~손절 거리
target_delta = old_target - db_buy # 평단~목표 거리
new_stop = real_buy - stop_delta
new_target = real_buy + target_delta
total_invested = real_buy * real_qty
# active_trades를 실계좌 기준으로 동기화 (체결 기준)
with self.db.conn:
self.db.conn.execute(
"""
UPDATE active_trades
SET
current_qty = ?,
target_qty = ?,
avg_buy_price = ?,
total_invested = ?,
current_price = ?,
max_price = MAX(max_price, ?),
stop_price = ?,
target_price = ?
WHERE code = ?
""",
(real_qty, real_qty, real_buy, total_invested,
real_cur, real_cur,
new_stop, new_target,
code),
)
logger.info(
f"✅ [{name}] 동기화 완료: "
f"수량 {db_qty}{real_qty}주, "
f"평단 {db_buy:.2f}{real_buy:.2f}, "
f"투입금 {trade.get('total_invested', 0):,.0f}{total_invested:,.0f}"
)
logger.info(f"✅ [동기화 완료] 실제:{len(real_codes)}개 | DB:{len(db_codes)}개 | 공통:{len(common_codes)}")
except Exception as e:
logger.error(f"❌ 계좌↔DB 동기화 실패: {e}")
import traceback
logger.error(f"상세 오류:\n{traceback.format_exc()}")
def sync_order_execution_from_api(self):
"""kt00007 / ka10076 체결 내역 보강용 INSERT (2초 간격)"""
try:
today_ymd = datetime.datetime.now().strftime("%Y%m%d")
# kt00007: 계좌별 주문·체결 내역 (체결내역만)
res = self.api._safe_request(
self.api.account.account_order_execution_detail_request_kt00007,
"4", "1", "0", "%",
ord_dt=today_ymd,
)
if res and res.get("acnt_ord_cntr_prps_dtl"):
for row in res["acnt_ord_cntr_prps_dtl"]:
self.db.insert_order_execution("kt00007", row, ord_dt=today_ymd, sell_tp="0")
logger.info(f"📥 [주문보강] kt00007 {len(res['acnt_ord_cntr_prps_dtl'])}건 저장")
time.sleep(2)
# ka10076: 체결 요청
res2 = self.api._safe_request(
self.api.account.filled_orders_request_ka10076,
"0", "0", "0",
)
if res2 and res2.get("cntr"):
for row in res2["cntr"]:
self.db.insert_order_execution("ka10076", row, sell_tp="0")
logger.info(f"📥 [주문보강] ka10076 {len(res2['cntr'])}건 저장")
except Exception as e:
logger.debug(f"주문 보강 조회 실패: {e}")
def cancel_stale_orders(self, max_age_seconds: int = 10):
"""
오늘자 미체결 주문 중 N초 이상 지난 잔량은 전량 취소.
- 단타 목적이 아니므로, 오래 안 체결되면 '안 되면 말고' 철학으로 정리.
- 기준: kt00007 (계좌별 주문·체결 내역)에서 ord_qty > cntr_qty 인 주문.
"""
try:
today_ymd = datetime.datetime.now().strftime("%Y%m%d")
now = datetime.datetime.now()
res = self.api._safe_request(
self.api.account.account_order_execution_detail_request_kt00007,
"4", "1", "0", "%", # (키움 TR 문서 기준 파라미터)
ord_dt=today_ymd,
)
if not res or not res.get("acnt_ord_cntr_prps_dtl"):
return
for row in res["acnt_ord_cntr_prps_dtl"]:
ord_no = row.get("ord_no") or row.get("orig_ord_no")
stk_cd = row.get("stk_cd")
ord_qty = int(str(row.get("ord_qty", "0") or "0"))
cntr_qty = int(str(row.get("cntr_qty", "0") or "0"))
ord_tm = row.get("ord_tm", "") # "HHMMSS"
# 전량 체결 또는 주문수량 0이면 스킵
if ord_qty <= 0 or cntr_qty >= ord_qty:
continue
# 주문 시각 → datetime
if len(ord_tm) == 6:
h, m, s = int(ord_tm[0:2]), int(ord_tm[2:4]), int(ord_tm[4:6])
ord_time = now.replace(hour=h, minute=m, second=s, microsecond=0)
else:
continue
age = (now - ord_time).total_seconds()
if age < max_age_seconds:
continue # 아직 기다릴 시간
logger.warning(
f"🚫 [미체결 취소 후보] {stk_cd} 주문번호 {ord_no} | "
f"주문수량={ord_qty}, 체결수량={cntr_qty}, 잔량={ord_qty-cntr_qty}, "
f"경과 {age:.1f}s"
)
cancel_res = self.api._safe_request(
self.api.order.stock_cancel_order_request_kt10003,
"KRX",
orig_ord_no=ord_no,
stk_cd=stk_cd,
cncl_qty="0", # 잔량 전부 취소
)
if isinstance(cancel_res, dict) and str(cancel_res.get("return_code", 0)) == "0":
logger.info(f"✅ [취소 성공] {stk_cd} 주문번호 {ord_no}")
else:
logger.error(f"❌ [취소 실패] {stk_cd} 주문번호 {ord_no} | 응답: {cancel_res}")
except Exception as e:
logger.error(f"❌ 미체결 취소 처리 실패: {e}")
def update_account_light(self, profit_val=0):
"""
경량 계좌 갱신 (매수/매도 직후 즉시 호출!)
- API 부하를 줄이기 위해 예수금만 빠르게 조회
- 총자산은 로컬 계산으로 추정
"""
try:
new_cash = self.api.get_deposit_only()
if new_cash > 0 or self.current_cash == 0:
self.current_cash = new_cash
# 손익 반영 (매도 시)
if profit_val != 0:
self.current_total_asset += profit_val
logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}")
except Exception as e:
logger.error(f"❌ 경량 갱신 실패: {e}")
def calculate_rsi(self, df, period=14):
"""RSI 계산"""
try:
if df is None or len(df) < period + 1:
return 50
delta = df['close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
rsi = 100 - (100 / (1 + rs))
return rsi.iloc[-1] if not np.isnan(rsi.iloc[-1]) else 50
except:
return 50
def calculate_atr(self, df, period=14):
"""ATR 계산"""
try:
if df is None or len(df) < period:
return 0
df = df.copy()
df['tr'] = np.maximum(
df['high'] - df['low'],
np.maximum(
np.abs(df['high'] - df['close'].shift()),
np.abs(df['low'] - df['close'].shift())
)
)
atr = df['tr'].rolling(window=period).mean().iloc[-1]
return atr if not np.isnan(atr) else 0
except:
return 0
def check_buy_signal_tail_catch(self, code, name):
"""
TAIL_CATCH_3M 전략: 3분봉 꼬리 잡기 (kiwoom_trader_dual.py 정식 버전)
- RSI 과열 체크 + 고가 추격 방지 + 꼬리 필터 강화 + 상세 로그
"""
try:
# 🚨 0. 거래 중단 체크 (일일 손실 한도 or 연속 손절)
if self.trading_halted:
logger.info(f"🔍 [Pass-거래중단] {name} {code}: STOP LOSS 발동 중")
return None
# 🚨 1. 금지 종목 체크 (손절 후 24시간 재매수 방지!)
if code in self.get_banned_codes():
logger.info(f"🔍 [Pass-금지종목] {name} {code}: 손절 후 24시간 내 재매수 불가")
return None
# 🚨 2. 종목 필터링 (스팩, ETN, 레버리지 등 제외!)
if not self._is_valid_stock(name, code):
return None
# 2. 데이터 조회
df = self.api.get_ohlcv(code, timeframe='3m', limit=50)
# 데이터 유효성 검사
if df is None or len(df) < 20:
logger.info(f"🔍 [데이터 부족] {name} {code}: DF 길이 {len(df) if df is not None else 0}")
return None
# 3. 지표 계산
ma20 = df['close'].rolling(window=20).mean().iloc[-1]
ma60 = df['close'].rolling(window=60).mean().iloc[-1] if len(df) >= 60 else ma20
avg_vol = df['volume'].rolling(window=20).mean().iloc[-1]
current_price = df['close'].iloc[-1]
current_vol = df['volume'].iloc[-1]
# ---------------------------------------------------------
# [필터 0] MA60 골든크로스 체크 (추가)
# MA20이 MA60을 상향 돌파 = 단타 타이밍 개선
if len(df) >= 60:
prev_ma20 = df['close'].rolling(window=20).mean().iloc[-2]
prev_ma60 = df['close'].rolling(window=60).mean().iloc[-2]
is_golden_cross = (ma20 > ma60) and (prev_ma20 <= prev_ma60)
if is_golden_cross:
logger.info(f"✨ [골든크로스] {name} {code}: MA20({ma20:.1f}) > MA60({ma60:.1f}) 상향 돌파 감지")
# 골든크로스가 아니어도 MA20 > MA60이면 통과 (추세 상승)
if ma20 < ma60:
logger.info(f"🔍 [Pass-MA60] {name} {code}: MA20({ma20:.1f}) < MA60({ma60:.1f}) 하락 추세")
return None
# ---------------------------------------------------------
# [필터 1] RSI 과열 체크
rsi = self.calculate_rsi(df)
if rsi >= self.rsi_threshold:
logger.info(f"🔍 [Pass-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {self.rsi_threshold})")
return None
# [필터 2] 🚨 피뢰침 방지 - 고점 추격 매수 방지
daily_high = df['high'].max()
high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", "0.96") # 고점 대비 4% 이상 조정
if current_price >= daily_high * high_chase_threshold:
drop_from_high = (daily_high - current_price) / daily_high * 100
logger.info(f"🔍 [Pass-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요)")
return None
# [필터 2-2] 🚨 피뢰침 방지 - 급등주 제외
daily_low = df['low'].min()
daily_change_pct = (daily_high - daily_low) / daily_low * 100
max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", "20.0") # 20% 이상 급등 제외
if daily_change_pct > max_daily_change:
logger.info(f"🔍 [Pass-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%)")
return None
# [필터 3] 20선 아래면 스킵 / 20선에서 너무 멀리 올라온 과열 구간도 스킵
if current_price < ma20:
logger.info(f"🔍 [Pass-MA20] {name} {code}: 현재가({current_price}) < MA20({ma20:.2f})")
return None
# 20선 초과 상한: MA20 대비 N% 이상 위면 고점 매수로 간주 → 스킵
ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", "3.0") # 기본 3% 초과 시 스킵
if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100):
gap_pct = (current_price - ma20) / ma20 * 100
logger.info(f"🔍 [Pass-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%)")
return None
# [필터 4] 최소 거래량 필터 (평균의 30%도 안되면 패스)
if current_vol < avg_vol * 0.3:
logger.info(f"🔍 [Pass-Vol] {name} {code}: 거래량 부족 ({current_vol} < {avg_vol * 0.3:.1f})")
return None
# ---------------------------------------------------------
# [타점 분석] 3분봉 꼬리 잡기 로직 + 캔들 패턴 검증
candle_open = df['open'].iloc[-1]
candle_close = df['close'].iloc[-1]
candle_high = df['high'].iloc[-1]
candle_low = min(df['low'].iloc[-1], current_price)
total_tail = candle_open - candle_low
if total_tail <= 0:
return None
# [캔들 패턴 검증] 망치형(Hammer) 검증
body = abs(candle_close - candle_open)
upper_shadow = candle_high - max(candle_open, candle_close)
lower_shadow = min(candle_open, candle_close) - candle_low
# 망치형 조건: 아래꼬리가 몸통의 2배 이상 + 윗꼬리는 매우 짧음
is_hammer_shape = (lower_shadow >= body * 2) and (upper_shadow <= body * 0.5)
# 거래량 폭발 여부 (가짜 망치 필터링)
volume_surge_ratio = current_vol / avg_vol if avg_vol > 0 else 1.0
is_volume_spike = volume_surge_ratio >= 1.5 # 거래량 1.5배 이상
# 진짜 망치인지 검증
if is_hammer_shape:
if is_volume_spike:
logger.info(f"🔨 [진짜 망치] {name} {code}: 거래량 {volume_surge_ratio:.1f}배 폭발 + 망치형 패턴")
else:
logger.info(f"⚠️ [가짜 망치] {name} {code}: 망치형이지만 거래량 부족({volume_surge_ratio:.1f}배) - 스킵")
return None
# 회복 비율 계산
recovery_ratio = (current_price - candle_low) / total_tail
# --- 로그로 상세 수치 확인 ---
volume_multiplier = get_env_float("VOLUME_AVG_MULTIPLIER", "1.0")
log_msg = (
f"🧐 분석({name} {code}): 가격{current_price} | MA20 {ma20:.1f} | "
f"회복률 {recovery_ratio:.2f} (조건:{self.min_recovery_ratio}~{self.max_recovery_ratio}) | "
f"거래량 {current_vol} (조건:>{avg_vol * volume_multiplier:.1f})"
)
# [조건 1] 회복 탄력성 (환경변수 사용: 기본 0.5~0.8)
if not (self.min_recovery_ratio <= recovery_ratio <= self.max_recovery_ratio):
logger.info(f"{log_msg} -> ❌ 회복률 미달/초과")
return None
# [조건 2] 시가 근접성 (환경변수 사용: 기본 99.5%)
candle_open_buffer = get_env_float("CANDLE_OPEN_PRICE_BUFFER", "0.995")
if current_price < candle_open * candle_open_buffer:
logger.info(f"{log_msg} -> ❌ 시가 회복 부족")
return None
# [조건 3] 거래량 폭발 여부 (환경변수 사용: 기본 평균 × 1.0)
if current_vol <= avg_vol * volume_multiplier:
logger.info(f"{log_msg} -> ❌ 거래량 파워 부족")
return None
# ---------------------------------------------------------
# [수급 필터] 최종 관문
foreigner, institution = self.api.get_intraday_investor(code)
logger.info(f"✨ 1차 통과({name} {code}): 수급 확인 중... 외인:{foreigner}, 기관:{institution}")
# 양쪽에서 동시에 대량 매도 중이면 스킵 (환경변수 사용: 기본 -1000)
intraday_threshold = get_env_int("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000")
if foreigner < intraday_threshold and institution < intraday_threshold:
logger.info(f"⛔ 수급 이탈 감지({name} {code}): 외인{foreigner} / 기관{institution} -> 매수 포기")
return None
# 5. 매수 시점 대/중/소형 조회 (거래대금 기준, 변동성 구간별 적용)
size_class = None
try:
base_dt = datetime.datetime.now().strftime("%Y%m%d")
res = self.api._safe_request(
self.api.chart.stock_daily_chart_request_ka10081,
code, base_dt, "1",
)
if res and res.get("stk_dt_pole_chart_qry"):
bars = res["stk_dt_pole_chart_qry"][:10]
values = [abs(float(b.get("trde_prica", 0) or 0)) for b in bars]
avg_trade_value = sum(values) / len(values) if values else 0
# .env 기준: 대형/중형 최소 거래대금 (원)
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}")
time.sleep(2) # API 부담 완화
except Exception as e:
logger.debug(f"대/중/소형 조회 스킵({code}): {e}")
# 6. 변동성 계산 및 매수 금액 산출
atr = self.calculate_atr(df)
# 켈리 비율 가져오기
kelly_fraction = None
if self.risk_mgr.use_kelly:
kelly_fraction = self.db.calculate_half_kelly()
# Risk Manager를 통한 기본 안전 매수 금액 계산 (변동성 역가중 + 대/중/소형 반영)
safe_amount = self.risk_mgr.get_position_size(
stock_name=name,
current_balance=self.current_cash,
df=df,
kelly_fraction=kelly_fraction,
size_class=size_class,
)
# 종목당 금액 상한(슬롯): 비싼/싼 종목 모두 비슷한 원화 포지션 → 손익률 대비 원화 손익 균등
use_slot_cap = os.environ.get("USE_SLOT_CAP", "true").lower() == "true"
if use_slot_cap and self.max_stocks > 0:
slot_cap_pct = get_env_float("SLOT_CAP_PCT", "0.9")
slot_money = int(self.current_cash * slot_cap_pct / self.max_stocks)
if safe_amount > slot_money:
logger.info(f"📐 [{name}] 슬롯 상한 적용: {safe_amount:,.0f}{slot_money:,.0f}")
safe_amount = slot_money
# 🔒 금액 손실 한도 기반 추가 캡 (예: 3.5% 손절 + 20만 원 손실 한도)
# - 손절 비율(self.stop_loss_pct)을 기준으로, 손실이 max_loss_per_trade_krw를 넘지 않도록
# 최대 포지션 금액 = max_loss_per_trade_krw / |stop_loss_pct| 로 역산
stop_pct_abs = abs(self.stop_loss_pct) if self.stop_loss_pct != 0 else 0.0
if stop_pct_abs > 0 and self.max_loss_per_trade_krw > 0:
max_position_by_loss = int(self.max_loss_per_trade_krw / stop_pct_abs)
if safe_amount > max_position_by_loss:
logger.info(
f"🛑 [{name}] 금액손실 한도 캡 적용: {safe_amount:,.0f}"
f"{max_position_by_loss:,.0f}원 (손절 {stop_pct_abs*100:.2f}% 기준, "
f"1트레이드 최대손실 {self.max_loss_per_trade_krw:,.0f}원)"
)
safe_amount = max_position_by_loss
if safe_amount < self.risk_mgr.min_amount:
logger.info(f"🚫 [{name}] 계산된 금액이 최소 매수액 미달")
return None
# 6. 수량 계산
qty = self.risk_mgr.calculate_quantity(current_price, safe_amount)
if qty < 1:
return None
# 손절가/목표가 설정 (ATR 기반)
atr_multiplier_stop = get_env_float("STOP_ATR_MULTIPLIER_TAIL", "3.5")
atr_multiplier_target = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", "8.0")
stop_price = current_price - (atr * atr_multiplier_stop)
target_price = current_price + (atr * atr_multiplier_target)
# 최종 매수 시그널
logger.info(f"🚀 [매수 신호] {name} {code}: 3분봉 꼬리 공략! (회복률: {recovery_ratio:.2f})")
# ---------------------------------------------------------
# [ML 필터] 승률 예측 (마지막 관문!)
if self.use_ml and self.ml_predictor:
# 피처 추출
ml_features = {
'rsi': rsi,
'volume_ratio': current_vol / avg_vol,
'tail_length_pct': (total_tail / candle_open) * 100,
'ma5_gap_pct': ((current_price - df['close'].rolling(5).mean().iloc[-1]) / current_price) * 100,
'ma20_gap_pct': ((current_price - ma20) / current_price) * 100,
'foreign_net_buy': foreigner,
'institution_net_buy': institution,
'market_hour': datetime.datetime.now().hour
}
# ML 승률 예측
win_prob = self.ml_predictor.predict_win_probability(ml_features)
logger.info(f"🤖 [ML 예측] {name} {code}: 승률 {win_prob:.2%} (임계값: {self.ml_min_prob:.2%})")
if win_prob < self.ml_min_prob:
logger.info(f"❌ [ML 필터] {name} {code}: 승률 부족 ({win_prob:.2%} < {self.ml_min_prob:.2%}) -> 매수 보류")
return None
else:
logger.info(f"✅ [ML 통과] {name} {code}: 승률 충분 ({win_prob:.2%}) -> 매수 진행!")
return {
'code': code,
'name': name,
'price': current_price,
'qty': qty,
'amount': safe_amount,
'strategy': 'TAIL_CATCH_3M',
'stop_price': stop_price,
'target_price': target_price,
'atr': atr,
'size_class': size_class,
}
except Exception as e:
logger.error(f"⚠️ 매수 시그널 분석 중 에러({name} {code}): {e}", exc_info=True)
return None
async def _execute_random_split_buy_async(self, code, name, qty, price, signal):
"""
랜덤 분할 매수 실행 (asyncio 태스크 — 스레드 제거). I/O는 run_in_executor / await sleep.
"""
loop = asyncio.get_event_loop()
try:
split_count = random.randint(10, 20)
base_qty = qty // split_count
remainder = qty % split_count
bought_qty = 0
logger.info(f"🔫 [진입 시작] {name}{qty}주 | {split_count}회 랜덤 분할 (async)")
for i in range(split_count):
qty_to_buy = base_qty + (remainder if i == split_count - 1 else 0)
if qty_to_buy <= 0:
continue
ok = await loop.run_in_executor(
None, lambda c=code, q=qty_to_buy: self.api.buy_market_order(c, q)
)
if ok:
bought_qty += qty_to_buy
logger.info(f"{i+1}/{split_count}: {qty_to_buy}주 체결")
if i < split_count - 1:
await asyncio.sleep(random.uniform(0.8, 1.2))
else:
logger.error(f"{i+1}/{split_count}: 매수 실패")
if bought_qty > 0:
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
trade_data = {
'code': code, 'name': name, 'avg_buy_price': price,
'stop_price': signal['stop_price'], 'target_price': signal['target_price'],
'atr_entry': signal['atr'], 'target_qty': qty, 'current_qty': bought_qty,
'total_invested': price * bought_qty, 'status': 'HOLDING',
'strategy': signal['strategy'], 'buy_date': now_str, 'max_price': price,
'size_class': signal.get('size_class'),
}
self.db.upsert_trade(trade_data)
self.update_account_light(profit_val=0)
total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
msg = f"💰 **[매수 완료]** {name}\n수량: {bought_qty}주 (랜덤 {split_count}회 분할)\n전략: {signal['strategy']}\n자산변동: {total_p:+.2f}%"
logger.info(msg)
self.send_mm(msg)
except Exception as e:
logger.error(f"❌ [{name}] 분할 매수 에러: {e}", exc_info=True)
finally:
async with self.split_buy_lock:
if code in self.active_split_buys:
del self.active_split_buys[code]
logger.debug(f"🧹 [{name}] 분할 매수 태스크 종료 및 정리 완료")
async def _execute_buy_async(self, signal):
"""
매수 실행 (async 진입점). 랜덤 분할은 asyncio 태스크로, 나머지는 executor에서 sync 실행.
"""
code = signal['code']
name = signal['name']
amount = signal['amount']
use_random_split = os.environ.get("USE_RANDOM_SPLIT", "true").lower() == "true"
if use_random_split and amount >= 100000:
async with self.split_buy_lock:
if code in self.active_split_buys:
task = self.active_split_buys[code]
if not task.done():
logger.warning(f"⚠️ [{name}] 이미 분할 매수 진행 중 -> 스킵")
return False
del self.active_split_buys[code]
t = asyncio.create_task(self._execute_random_split_buy_async(
code, name, signal['qty'], signal['price'], signal
))
self.active_split_buys[code] = t
logger.info(f"🚀 [{name}] 분할 매수 태스크 시작 (메인 루프 계속 진행)")
return True
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: self.execute_buy(signal))
def execute_buy(self, signal):
"""
매수 실행 (TWAP 또는 즉시 매수). 랜덤 분할은 _execute_buy_async에서 asyncio 태스크로 처리.
"""
try:
code = signal['code']
name = signal['name']
qty = signal['qty']
amount = signal['amount']
price = signal['price']
active_trades = self.db.get_active_trades()
if code in active_trades:
logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵")
return False
# ==========================================================
# [옵션 1] TWAP 분할 매수
# ==========================================================
if self.use_twap and self.executor and amount >= 1000000: # 100만원 이상만 분할
self.executor.add_order(code, name, amount, duration_minutes=30)
logger.info(f"📝 [{name}] TWAP 분할 매수 등록: {amount:,.0f}")
return True
# ==========================================================
# [옵션 3] 일반 매수 (즉시 일괄 실행)
# ==========================================================
else:
success = self.api.buy_market_order(code, qty)
if success:
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
trade_data = {
'code': code,
'name': name,
'avg_buy_price': price,
'stop_price': signal['stop_price'],
'target_price': signal['target_price'],
'atr_entry': signal['atr'],
'target_qty': qty,
'current_qty': qty,
'total_invested': price * qty,
'status': 'HOLDING',
'strategy': signal['strategy'],
'buy_date': now_str,
'max_price': price,
'size_class': signal.get('size_class'),
}
self.db.upsert_trade(trade_data)
# 매수 후 즉시 예수금 갱신!
self.update_account_light(profit_val=0)
total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
msg = f"💰 **[매수 체결]** {name}\n가격: {price:,.0f}× {qty}\n자산변동: {total_p:+.2f}%"
logger.info(msg)
self.send_mm(msg)
return True
return False
except Exception as e:
logger.error(f"❌ 매수 실행 실패: {e}")
return False
def check_sell_signals(self):
"""
보유 종목 매도 시그널 체크 (Dual 로직 완전 이식)
- 어깨 매도 (최우선!)
- 스캘핑 로직 (본절사수/익절보존)
- 2일 보유 전략
"""
active_trades = self.db.get_active_trades()
if not active_trades:
return
for code, trade in list(active_trades.items()):
try:
name = trade['name']
buy_price = trade['avg_buy_price']
stop_price = trade['stop_price']
target_price = trade['target_price']
qty = trade['current_qty']
strategy = trade.get('strategy', 'TAIL_CATCH_3M')
buy_date_str = trade.get('buy_date', '')
atr = trade.get('atr_entry', buy_price * 0.01)
# 현재가 조회
current_price = self.api.get_current_price(code)
if not current_price:
continue
# 고점 갱신
max_price = trade.get('max_price', buy_price)
if current_price > max_price:
max_price = current_price
self.db.update_max_price(code, current_price)
# DB 현재가 업데이트
self.db.update_current_price(code, current_price)
# 손익률 계산 (소수 단위, 예: 0.01 = +1%)
profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
profit_val = (current_price - buy_price) * qty
# POP/LOCK 순수익 계산을 위한 손익 경로 상태 업데이트
state = self.trade_state.get(code, {
"max_profit_pct": 0.0,
"min_profit_pct": 0.0,
"went_negative": False,
})
max_profit_pct = max(
state.get("max_profit_pct", 0.0),
(max_price - buy_price) / buy_price if buy_price > 0 else 0,
)
min_profit_pct = min(state.get("min_profit_pct", 0.0), profit_pct)
went_negative = state.get("went_negative", False) or (profit_pct < 0)
state["max_profit_pct"] = max_profit_pct
state["min_profit_pct"] = min_profit_pct
state["went_negative"] = went_negative
self.trade_state[code] = state
# 수수료+세금 왕복 비용을 고려한 순수익률 (대략적인 추정)
net_pct = profit_pct - self.round_trip_cost_pct
max_net_pct = max_profit_pct - self.round_trip_cost_pct
# 매도 사유 판단
sell_reason = None
# 금액 기준 손절 (원 단위)
if profit_val <= -self.max_loss_per_trade_krw:
sell_reason = "금액손실컷"
# ==========================================================
# [필수 1] 어깨 매도 (Shoulder Cut) - 최우선!
# 고점 대비 3% 이상 빠지면 수익/손실 불문하고 즉시 탈출
# ==========================================================
drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0
if drop_from_high >= self.shoulder_cut_pct:
# 어깨 구간에서도 순수익 0.3% 바닥 룰을 한 번 더 체크
# - lock_net_pct 조건을 만족하면 "순수익0.3보존"으로 기록
# - 그렇지 않으면 기존대로 어깨매도
if (
max_net_pct > self.lock_net_pct
and not went_negative
and net_pct <= self.lock_net_pct
):
sell_reason = "순수익0.3보존"
else:
sell_reason = f"어깨매도(고점대비-{drop_from_high * 100:.1f}%)"
# 어깨 매도가 아니면 다른 로직 체크
if not sell_reason:
# 매수 경과 시간 계산
hours_passed = 0
if buy_date_str:
try:
buy_time = datetime.datetime.strptime(buy_date_str, '%Y-%m-%d %H:%M:%S')
hours_passed = (datetime.datetime.now() - buy_time).total_seconds() / 3600
except:
hours_passed = 0
# ==========================================================
# [통합 매도 로직] TAIL/RECOVERED 구분 없이 동일 적용
# ==========================================================
use_quick_profit = os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true").lower() == "true"
# [빠른 익절] 작은 수익 보호 (매수 후 30분 이내)
if use_quick_profit and hours_passed < 0.5:
if max_profit_pct >= 0.005 and profit_pct <= 0.0015:
sell_reason = "💨 작은수익보호"
# [스캘핑 1] 본절사수
if not sell_reason and (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2):
sell_reason = "스캘핑_본절사수"
# [스캘핑 2] 익절보존
if not sell_reason and current_price < (max_price - atr * 1.0) and profit_pct > 0:
sell_reason = "스캘핑_익절보존"
# [순수익 0.3% 바닥 보존]
# - 진입 이후 한 번도 손실 구간을 찍지 않고 (went_negative=False)
# - 순수익이 lock_net_pct(기본 +0.3%)를 한 번이라도 초과했다가
# - 다시 lock_net_pct 이하로 되돌아오면 강제 익절
if (
not sell_reason
and max_net_pct > self.lock_net_pct
and not went_negative
and net_pct <= self.lock_net_pct
):
sell_reason = "순수익0.3보존"
# [2일 보유 전략]
if not sell_reason:
if hours_passed < 48:
if profit_pct > 0.05:
sell_reason = "💰 2일내 5%+ 익절"
elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97:
sell_reason = "📈 2일내 고점7% 찍고 3% 하락"
else:
if profit_pct > 0.02:
sell_reason = "⏰ 2일 경과 2%+ 익절"
elif profit_pct > 0 and current_price < max_price * 0.97:
sell_reason = "⏰ 2일 경과 익절보호"
# [본절/트레일링] 위 조건에 안 걸렸을 때
if not sell_reason:
if max_profit_pct >= 0.015 and profit_pct <= 0.005:
sell_reason = "본절보호"
elif profit_pct > 0 and max_profit_pct >= 0.03 and current_price < max_price * 0.99:
sell_reason = "트레일링스탑"
# ==========================================================
# [공통] 최후의 보루 (목표가 달성 및 손절)
# ==========================================================
if not sell_reason:
if current_price >= target_price:
sell_reason = "목표달성"
elif profit_pct <= self.stop_loss_pct:
sell_reason = f"칼손절({profit_pct * 100:.1f}%)"
elif current_price <= stop_price:
sell_reason = "전략손절"
# ==========================================================
# [매도 실행]
# ==========================================================
if sell_reason:
if self.api.sell_market_order(code, qty):
env_snapshot = json.dumps(self.get_env_snapshot(), ensure_ascii=False)
self.db.close_trade(code, current_price, sell_reason, env_snapshot=env_snapshot, size_class=trade.get('size_class'))
# 🚨 손절 시 24시간 재매수 금지 추가!
is_loss = profit_pct < 0
is_stop_loss = "손절" in sell_reason or "어깨" in sell_reason or "스캘핑" in sell_reason
if is_loss and is_stop_loss:
self.add_ban(code, name)
logger.warning(f"🚫 [{name}] 손절 발생 → {self.ban_hours}시간 재매수 금지")
# 매도 후 즉시 예수금 갱신 및 총자산 추정!
self.update_account_light(profit_val)
# 🚨 리스크 상태 체크 (손실 누적 시 거래 중단!)
self.check_risk_status()
total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
icon = "💰" if profit_pct > 0 else "💧"
r_tag = "[RECOVERED] " if strategy == "RECOVERED" else ""
ban_tag = " 🚫24h금지" if (is_loss and is_stop_loss) else ""
msg = (
f"{icon} **[매도] {r_tag}{name}**{ban_tag}\n"
f"수익: {profit_pct * 100:.2f}% ({profit_val:,.0f}원)\n"
f"사유: {sell_reason}\n"
f"누적: {total_p:+.2f}%"
)
logger.info(msg)
self.send_mm(msg)
except Exception as e:
logger.error(f"❌ [{code}] 매도 체크 실패: {e}")
def process_twap_orders(self):
"""TWAP 분할 매수 처리"""
if not self.use_twap or not self.executor:
return
# 현재가 정보 수집
active_orders = self.executor.get_status()
if not active_orders:
return
current_prices = {}
for code in active_orders.keys():
price = self.api.get_current_price(code)
if price:
current_prices[code] = price
# 매수 콜백 함수
def buy_callback(code, name, amount, price):
qty = int(amount / price)
if qty < 1:
return False
success = self.api.buy_market_order(code, qty)
if success:
# DB 업데이트 (분할 매수 누적)
active_trades = self.db.get_active_trades()
if code in active_trades:
# 기존 보유분 있음 -> 평단가 계산
existing = active_trades[code]
old_qty = existing['current_qty']
old_price = existing['avg_buy_price']
old_invested = existing['total_invested']
new_qty = old_qty + qty
new_invested = old_invested + (price * qty)
new_avg_price = new_invested / new_qty
self.db.upsert_trade({
'code': code,
'name': name,
'avg_buy_price': new_avg_price,
'current_qty': new_qty,
'total_invested': new_invested,
'status': 'HOLDING',
**existing # 기존 정보 유지
})
else:
# 신규 매수
self.db.upsert_trade({
'code': code,
'name': name,
'avg_buy_price': price,
'target_qty': qty, # 임시
'current_qty': qty,
'total_invested': price * qty,
'status': 'HOLDING',
'strategy': 'TAIL_CATCH_3M'
})
return True
return False
# TWAP 처리
self.executor.process_orders(current_prices, buy_callback)
def update_universe(self):
"""
매수 후보군 업데이트 (5분마다)
- 개미털기 우선 (원본 점수 유지)
- 외국인/거래량/상승률/기관 추가 (보너스 점수)
- 강도 4 이상만 필터링 → Top 30
"""
logger.info(f"🔄 [리스트 갱신] 예수금: {self.current_cash:,.0f}")
logger.info(f"📡 [복합 스캔] 개미털기 우선 + 4가지 보너스 소스")
# 매수 가능 금액 계산
slot_money = int(self.current_cash * 0.9 / self.max_stocks) if self.max_stocks > 0 else 100000
all_candidates = {} # {code: {name, price, base_score, bonus_score}}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 1. 개미털기 (눌림목) - 원본 점수 100% 유지!
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
try:
ant_shaking = self.api.scan_ant_shaking_candidates(max_price_limit=slot_money)
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
}
except Exception as e:
logger.warning(f" ⚠️ [개미털기] 수집 실패: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 2. 외국인 순매수 - 보너스 +0.5 (개미털기 있으면 추가)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
try:
foreign_buy = self.api.get_foreign_consecutive_buy(consecutive_days=2, limit=30)
logger.info(f" ✅ [외국인] {len(foreign_buy)}개 수집")
for idx, item in enumerate(foreign_buy):
code = item['stk_cd'].strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.5 # 순위별 보너스 (최대 +0.5)
if code in all_candidates:
# 개미털기에 이미 있으면 보너스만 추가
all_candidates[code]['bonus_score'] += bonus
else:
# 개미털기에 없으면 새로 추가 (기본 점수 3점)
all_candidates[code] = {
'code': code,
'name': item.get('stk_nm', '').strip(),
'price': 0,
'base_score': 3.0, # 외국인만 있으면 기본 3점
'bonus_score': bonus,
'from_ant': False
}
except Exception as e:
logger.warning(f" ⚠️ [외국인] 수집 실패: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 3. 거래량 급증 - 보너스 +0.3
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
try:
volume_surge = self.api.get_volume_surge_stocks(min_volume="50", limit=30)
logger.info(f" ✅ [거래량] {len(volume_surge)}개 수집")
for idx, item in enumerate(volume_surge):
code = item['stk_cd'].strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.3
if code in all_candidates:
all_candidates[code]['bonus_score'] += bonus
else:
all_candidates[code] = {
'code': code,
'name': item.get('stk_nm', '').strip(),
'price': float(item.get('prpr', 0)),
'base_score': 2.5,
'bonus_score': bonus,
'from_ant': False
}
except Exception as e:
logger.warning(f" ⚠️ [거래량] 수집 실패: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 4. 상승률 상위 - 보너스 +0.2
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
try:
price_movers = self.api.get_top_price_movers(sort_type="1", limit=30)
logger.info(f" ✅ [상승률] {len(price_movers)}개 수집")
for idx, item in enumerate(price_movers):
code = item['stk_cd'].strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.2
if code in all_candidates:
all_candidates[code]['bonus_score'] += bonus
else:
all_candidates[code] = {
'code': code,
'name': item.get('stk_nm', '').strip(),
'price': float(item.get('prpr', 0)),
'base_score': 2.0,
'bonus_score': bonus,
'from_ant': False
}
except Exception as e:
logger.warning(f" ⚠️ [상승률] 수집 실패: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 5. 기관 순매수 - 보너스 +0.3
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
try:
inst_buy = self.api.get_institutional_buy_stocks(limit=30)
logger.info(f" ✅ [기관] {len(inst_buy)}개 수집")
for idx, item in enumerate(inst_buy):
code = item['stk_cd'].strip()
if len(code) != 6:
continue
bonus = (30 - idx) / 30.0 * 0.3
if code in all_candidates:
all_candidates[code]['bonus_score'] += bonus
else:
all_candidates[code] = {
'code': code,
'name': item.get('stk_nm', '').strip(),
'price': float(item.get('prpr', 0)),
'base_score': 2.5,
'bonus_score': bonus,
'from_ant': False
}
except Exception as e:
logger.warning(f" ⚠️ [기관] 수집 실패: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 6. 구글 트렌드 괴리율 - 보너스 +0.5 (검색량 급증 vs 주가 미반영)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if self.trend_analyzer:
# Rate Limit 체크: 429 에러 발생 시 스킵
if hasattr(self.trend_analyzer, 'rate_limit_hit') and self.trend_analyzer.rate_limit_hit:
logger.warning(" ⚠️ [트렌드 괴리율] Rate Limit 상태 - 이번 스캔 스킵")
else:
try:
# 후보 종목 리스트 준비 (Top 5만 분석 - Rate Limit 방지)
stock_list = [{'code': code, 'name': data['name']} for code, data in all_candidates.items()]
trend_scores = self.trend_analyzer.analyze_stocks(stock_list[:5]) # 최대 5개만 분석 (429 방지)
logger.info(f" ✅ [트렌드 괴리율] {len(trend_scores)}개 종목 분석 완료")
for code, score in trend_scores.items():
if score > 0:
bonus = score * 0.5 # 최대 +0.5점
if code in all_candidates:
all_candidates[code]['bonus_score'] += bonus
logger.info(f" 🔍 [{all_candidates[code]['name']}] 검색량 급증 보너스 +{bonus:.2f}")
else:
# 트렌드만 있고 다른 조건 없으면 기본 점수 2.0으로 추가
all_candidates[code] = {
'code': code,
'name': next((s['name'] for s in stock_list if s['code'] == code), ''),
'price': 0,
'base_score': 2.0,
'bonus_score': bonus,
'from_ant': False
}
except Exception as e:
logger.warning(f" ⚠️ [트렌드 괴리율] 분석 실패: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 7. 관세청 수출 호재 종목 추가 - 보너스 +1.0 (즉시 추가)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if self.export_sniper:
try:
hot_stocks = self.export_sniper.get_hot_stocks()
if hot_stocks:
logger.info(f" ✅ [수출 호재] {len(hot_stocks)}개 종목 포착")
for stock in hot_stocks:
code = stock['code']
if code in all_candidates:
all_candidates[code]['bonus_score'] += 1.0 # 호재는 큰 보너스
logger.info(f" 🔥 [{stock['name']}] 수출 호재 보너스 +1.0점: {stock['reason']}")
else:
# 호재 종목은 즉시 추가 (기본 점수 3.5)
all_candidates[code] = {
'code': code,
'name': stock['name'],
'price': 0,
'base_score': 3.5,
'bonus_score': 1.0,
'from_ant': False
}
logger.info(f" 🚀 [{stock['name']}] 수출 호재 즉시 추가: {stock['reason']}")
except Exception as e:
logger.warning(f" ⚠️ [수출 호재] 감지 실패: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 최종 점수 계산 및 필터링
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
final_candidates = []
ant_count = 0
bonus_count = 0
for code, data in all_candidates.items():
# 총점 = 기본 점수 + 보너스
total_score = data['base_score'] + data['bonus_score']
# 강도 4 이상만 필터링!
if total_score >= 4.0:
final_candidates.append({
'code': code,
'name': data['name'],
'score': total_score,
'price': data['price'],
'scan_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
if data['from_ant']:
ant_count += 1
else:
bonus_count += 1
if not final_candidates:
logger.info(" ⚠️ 스캔 결과 없음 (강도 4 이상 종목 없음)")
return
# 점수 순 정렬
final_candidates.sort(key=lambda x: x['score'], reverse=True)
# Top 30만 유지
final_candidates = final_candidates[:30]
# DB에 저장
self.db.update_target_candidates(final_candidates)
# Top 10 로그 + Mattermost 알림
logger.info(f"📊 [스캔 완료] 총 {len(final_candidates)}개 종목 (개미털기:{ant_count} + 보너스:{bonus_count})")
top_picks = [f"{x['name']}({x['score']:.1f})" for x in final_candidates[:10]]
logger.info(f" 🔝 Top 10: {', '.join(top_picks)}")
# MM에 브리핑
try:
msg = (
f"📊 **[종목 스캔 완료]**\n"
f"- 개미털기: {ant_count}개 (원본 점수 유지)\n"
f"- 보너스 추가: {bonus_count}개 (외국인/거래량/상승률/기관)\n"
f"- 최종 선정: {len(final_candidates)}개 (강도 4+ 필터)\n"
f"- Top 5: {', '.join(top_picks[:5])}"
)
self.send_mm(msg)
except Exception as e:
logger.error(f"❌ 스캔 MM 전송 실패: {e}")
def load_target_universe(self):
"""매수 후보군 조회 (DB에서!)"""
return self.db.get_target_candidates()
async def _news_monitor_loop(self):
"""뉴스 감시 루프 (asyncio 태스크 — 스레드 제거). I/O는 await로 대기."""
if not self.export_sniper:
return
self.news_monitor_running = True
logger.info("📡 [뉴스 감시] 비동기 태스크 시작")
loop = asyncio.get_event_loop()
while self.news_monitor_running:
try:
# 관세청 수출 호재 감지 (동기 I/O → executor에서 실행 후 await)
hot_stocks = await loop.run_in_executor(
None, lambda: self.export_sniper.get_hot_stocks()
)
if hot_stocks:
candidates = self.db.get_target_candidates()
existing_codes = {c['code'] for c in candidates}
new_stocks = []
for stock in hot_stocks:
if stock['code'] not in existing_codes:
new_stocks.append({
'code': stock['code'],
'name': stock['name'],
'score': 4.5,
'price': 0,
'scan_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
if new_stocks:
all_candidates = candidates + new_stocks
all_candidates.sort(key=lambda x: x['score'], reverse=True)
all_candidates = all_candidates[:30]
self.db.update_target_candidates(all_candidates)
msg = (
f"🔥 **[수출 호재 포착]**\n"
f"- {len(new_stocks)}개 종목 즉시 추가:\n"
)
for stock in new_stocks:
msg += f"{stock['name']} ({stock['code']})\n"
self.send_mm(msg)
logger.info(f"🔥 [수출 호재] {len(new_stocks)}개 종목 즉시 추가됨")
await asyncio.sleep(60)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"❌ 뉴스 감시 에러: {e}")
await asyncio.sleep(60)
logger.info("📡 [뉴스 감시] 태스크 종료")
def start_news_monitor(self):
"""뉴스 감시 태스크는 _run_async() 내에서 create_task로 시작 (스레드 제거)"""
pass
@staticmethod
def _seconds_until_next_5min():
"""다음 5분 정각(:00, :05, :10...)까지 남은 초 수. 스캔을 정확히 5분 간격으로 맞추기 위함."""
now = datetime.datetime.now()
# 현재 분을 5분 단위로 올림 → 다음 5분 정각
next_min = (now.minute // 5 + 1) * 5
if next_min >= 60:
next_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
else:
next_time = now.replace(minute=next_min, second=0, microsecond=0)
return (next_time - now).total_seconds()
async def _universe_scan_scheduler(self):
"""
5분마다 정각에 유니버스 스캔 실행. (블로킹 방지: run_in_executor로 실행)
- 기존: 메인 루프에서 update_universe() 호출 시 스캔이 길어져 다음 :05를 놓침 → 10분/15분 간격 발생
- 변경: 이 태스크만 다음 5분 정각까지 sleep 후 스캔 → 21:50, 21:55, 22:00 ... 고정 간격
"""
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 = datetime.datetime.now()
logger.info(f"🔄 [스캔 주기] 정각 스캔 시작 | 시각:{now.hour:02d}:{now.minute:02d}:{now.second:02d}")
await loop.run_in_executor(None, self.update_universe)
self.cleanup_banned_list()
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)
def run(self):
"""메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출."""
asyncio.run(self._run_async())
async def _run_async(self):
"""메인 루프 (async). 5분 스캔/뉴스 감시는 별도 태스크, I/O 대기는 await."""
logger.info("🚀 트레이딩봇 Ver2 가동 시작 (async)")
# 이벤트 루프 내에서 Lock 생성 (asyncio.Lock은 루프 필요)
if self.split_buy_lock is None:
self.split_buy_lock = asyncio.Lock()
# 🔥 봇 시작 시 계좌 ↔ DB 동기화 (최초 1회)
self.sync_portfolio_from_api()
# 뉴스 감시 태스크 시작 (스레드 대신 asyncio 태스크)
if self.export_sniper and not self.news_monitor_running:
self._news_monitor_task = asyncio.create_task(self._news_monitor_loop())
logger.info("✅ 뉴스 감시 태스크 시작 완료")
# 5분 정각 스캔 스케줄러 태스크 시작 (update_universe는 executor에서 실행 → 메인 루프 블로킹 없음)
self._scan_task = asyncio.create_task(self._universe_scan_scheduler())
# 봇 시작 시 장 상태 확인
first_check = self.api.check_market_status()
if self.force_market_open:
logger.info("⚠️ FORCE_MARKET_OPEN 활성화 - 장 상태 무시(테스트 모드)")
first_check = True
if not first_check:
self.send_mm("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.")
logger.info("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.")
loop_count = 0
while True:
try:
loop_count += 1
current_time = datetime.datetime.now()
current_hour = current_time.hour
current_minute = current_time.minute
current_second = current_time.second
# 0. 날짜 변경 체크
today = current_time.strftime("%Y%m%d")
if today != self.today_date:
logger.info(f"📅 날짜 변경: {self.today_date}{today}")
self.today_date = today
self.start_day_asset = self.current_total_asset
self.morning_report_sent = False
self.closing_report_sent = False
self.final_report_sent = False
self.news_analyzed_today = False
self.trading_halted = False # 🔓 거래 중단 해제 (새로운 날!)
self._first_sync_done = False # 다음 날 첫 동기화 허용
# 🧹 금지 종목 정리 (만료된 종목 제거)
self.cleanup_banned_list()
# ML 모델 재학습 체크 (월요일마다 or 7일 경과 시)
if self.use_ml and self.ml_predictor:
if current_time.weekday() == 0 or self.ml_predictor.should_retrain():
logger.info("🤖 [ML 재학습] 새로운 주 시작 or 7일 경과 → 모델 재학습 시작")
self.ml_predictor.train_model(retrain=True)
# 1. 장 운영 시간 체크
is_open = self.api.check_market_status()
if self.force_market_open:
is_open = True
# 장 시작/마감 이벤트 처리
if is_open and not self.was_market_open:
self.refresh_account()
self.check_risk_status() # 🚨 리스크 상태 체크
self.was_market_open = True
self.send_mm("🌅 **[장 시작]** 봇이 매매를 시작합니다.")
logger.info("🌅 [장 시작] 매매 시작!")
elif not is_open and self.was_market_open:
self.was_market_open = False
self.refresh_account()
day_profit = self.current_total_asset - self.start_day_asset
self.send_mm(f"🌙 **[장 마감]**\n오늘 손익: {day_profit:,.0f}")
logger.info(f"🌙 [장 마감] 오늘 손익: {day_profit:,.0f}")
self.is_first_run = True # 다음 날 스캔 스케줄러용
self._first_sync_done = False # 다음 날 첫 동기화 허용
# 장 휴장 시 대기
if not is_open:
if loop_count % 60 == 0: # 1분마다
logger.info("💤 장 운영 시간 외")
await asyncio.sleep(60)
continue
# [유니버스 갱신] → _universe_scan_scheduler 태스크에서 5분 정각에 실행 (여기서 호출 안 함)
# 생존 신고 (1분마다)
if current_minute % 1 == 0 and current_second < 2:
active_count = len(self.db.get_active_trades())
targets = self.load_target_universe()
logger.info(f"👀 [생존] 타겟:{len(targets)} | 보유:{active_count}/{self.max_stocks} | 예수금:{self.current_cash:,.0f}")
await asyncio.sleep(2)
# 2. 시간대별 리포트 (리포트 전 계좌 조회 + 동기화!)
if current_hour == 13 and current_minute == 0 and not self.morning_report_sent:
self.refresh_account()
self.sync_portfolio_from_api() # 리포트 전 DB 동기화
self.send_morning_report()
elif current_hour == 15 and current_minute == 15 and not self.closing_report_sent:
self.refresh_account()
self.sync_portfolio_from_api()
self.send_closing_report()
elif current_hour == 15 and current_minute >= 35 and not self.final_report_sent:
self.refresh_account()
self.sync_portfolio_from_api()
self.send_final_report()
# 2-1. 뉴스 AI 분석 (하루 1회)
if self.use_news and self.news_analyzer:
if current_hour == self.news_hour and current_minute == 0 and not self.news_analyzed_today:
self.analyze_and_send_news()
# 3. [제거됨] 계좌 정보 갱신
# - 시작 시 1회만 조회
# - 매수/매도 후 update_account_light()로 즉시 갱신
# - 5분마다 자동 조회는 불필요 (API 낭비)
# 4. TWAP 분할 매수 처리
if self.use_twap:
self.process_twap_orders()
# 5. [동기화 → 주문 정리 → 매도 → 매수] 계좌↔DB 동기화 (첫 1회 + 이후 2분마다)
first_sync_done = getattr(self, "_first_sync_done", False)
if not first_sync_done:
self.sync_portfolio_from_api()
self._first_sync_done = True
logger.info(f"🔄 [첫실행] 동기화 완료")
elif current_minute % 2 == 0 and current_second < 5:
self.sync_portfolio_from_api()
# 미체결 주문 정리 (N초 이상 미체결 주문 취소)
self.cancel_stale_orders(max_age_seconds=10)
# 5분마다 주문·체결 보강 (kt00007, ka10076) — API 2회 + 2초 sleep
if current_minute % 5 == 0 and current_second < 8:
self.sync_order_execution_from_api()
# 6. 보유 종목 매도 체크
self.check_sell_signals()
# 7. 새로운 매수 기회 탐색
active_count = len(self.db.get_active_trades())
if not self.trading_halted and active_count < self.max_stocks:
targets = self.load_target_universe()
if not targets:
# 타겟이 0개면 매수 체크 로직 안 돌아감
if loop_count % 60 == 0: # 1분마다 한 번만
logger.info(f"⚠️ [매수 체크 스킵] 타겟 0개 (DB 저장 실패 or 스캔 결과 없음)")
else:
logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(targets)}개 | 보유:{active_count}/{self.max_stocks}")
for item in targets:
code = item['code']
name = item['name']
# 이미 보유 중이면 스킵
active_trades = self.db.get_active_trades()
if code in active_trades:
continue
# 분할 매수 진행 중인 종목 스킵 (asyncio 태스크)
async with self.split_buy_lock:
if code in self.active_split_buys:
task = self.active_split_buys[code]
if not task.done():
continue # 아직 진행 중이면 스킵
# 돈 없으면 스탑
if self.current_cash < self.risk_mgr.min_amount:
break
# 매수 시그널 확인
signal = self.check_buy_signal_tail_catch(code, name)
if signal:
await self._execute_buy_async(signal)
await asyncio.sleep(1) # 매수 후 1초 대기
# 7. 대기 (1초마다 체크 - 단타 타이밍 중요!)
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("⏸️ 사용자 중단")
break
except Exception as e:
import traceback
logger.error(f"❌ 메인 루프 에러: {e}")
logger.error(f"상세 오류:\n{traceback.format_exc()}")
logger.info("⏳ 예외 발생 -> 5초 대기 후 재시도")
await asyncio.sleep(5)
# 종료 처리 (스캔/뉴스 태스크 취소)
logger.info("🛑 봇 종료 중...")
if getattr(self, "_scan_task", None) and not self._scan_task.done():
self._scan_task.cancel()
try:
await self._scan_task
except asyncio.CancelledError:
pass
if self._news_monitor_task and not self._news_monitor_task.done():
self.news_monitor_running = False
self._news_monitor_task.cancel()
try:
await self._news_monitor_task
except asyncio.CancelledError:
pass
if self.db:
self.db.close()
logger.info("✅ 정상 종료 완료")
# ==========================================================
# [메인 실행]
# ==========================================================
if __name__ == "__main__":
try:
broker = BrokerAPI()
bot = TradingBotV2(broker)
bot.run()
except Exception as e:
logger.critical(f"💀 봇 실행 실패: {e}")
raise e