3180 lines
149 KiB
Python
3180 lines
149 KiB
Python
"""
|
||
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
|