[잔고 관리] - _today_buy_total 인스턴스 변수로 당일 누적 매수 추적 (KIS T+2 미차감 보완) - MAX_BUY_PER_CYCLE, MAX_DAILY_BUY_RATIO 설정 추가 - available_deposit = max_daily_buy - effective_today_buy 계산 [앙상블 & 포지션 사이징] - AdaptiveEnsemble 실제 연동 (하드코딩 가중치 제거) - Kelly Criterion Half-Kelly 포지션 비중 계산 - SignalWeights.normalize() Water-Filling 알고리즘으로 경계 위반 해결 - _accuracy_weighted() 크기 가중 정확도로 통일 - ensemble_weights.json → ensemble_history.json 통합 [LLM 클라이언트] - GeminiLLMClient 추가 (Gemini → Ollama 폴백 체인) - _class_last_call_ts 클래스 변수로 워커 재시작 후에도 스로틀 유지 - Ollama 미실행 조기 감지 및 명확한 오류 메시지 [KIS API] - 모든 requests.get/post에 timeout=Config.HTTP_TIMEOUT 적용 - get_balance()에 today_buy_amt 필드 추가 [장중 전용 운영] - KRXCalendar: exchange_calendars 기반, 2024~2026 공휴일 하드코딩 폴백 - EOD 셧다운: 15:35에 전체 상태 저장 후 서버 자동 종료 - Watchdog: .eod_date 마커로 EOD 후 재시작 차단 - daily_launcher.py: 매일 08:30 실행, 휴장일 감지 후 봇 미시작 - Windows 작업 스케줄러 WebAI_DailyLauncher 등록 [텔레그램 스킬 수정] - PYTHONIOENCODING=utf-8 서브프로세스 환경 설정 (cp949 이모지 오류 해결) - /regime: IPC macro_indices 파싱 구현, --json 모드 input() 블로킹 제거 - /weights: ensemble_history.json 형식 파싱 업데이트 - /model_health: glob 패턴 *_v3.pt 수정 - /postmortem: 거래 없을 때 빈 JSON 출력으로 Telegram 오류 해결 - /macro: price=0 시 prev_close 폴백 표시 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
907 lines
40 KiB
Python
907 lines
40 KiB
Python
import asyncio
|
|
import os
|
|
import json
|
|
import time
|
|
from concurrent.futures import ProcessPoolExecutor
|
|
from concurrent.futures.process import BrokenProcessPool
|
|
from datetime import datetime, timedelta
|
|
|
|
from modules.config import Config
|
|
from modules.services.kis import KISClient
|
|
from modules.services.news import AsyncNewsCollector
|
|
from modules.services.ollama import OllamaManager
|
|
from modules.services.telegram import TelegramMessenger
|
|
from modules.analysis.macro import MacroAnalyzer
|
|
from modules.utils.monitor import SystemMonitor
|
|
from modules.utils.performance_db import PerformanceDB
|
|
from modules.strategy.process import analyze_stock_process, calculate_position_size
|
|
from modules.analysis.ensemble import get_ensemble
|
|
|
|
try:
|
|
from theme_manager import ThemeManager
|
|
except ImportError:
|
|
class ThemeManager:
|
|
def get_themes(self, code): return []
|
|
|
|
|
|
def init_worker():
|
|
try:
|
|
from modules.utils.process_tracker import ProcessTracker
|
|
ProcessTracker.register("Trading Bot Worker")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class AutoTradingBot:
|
|
"""
|
|
[v2.0] 개선된 자동매매 봇
|
|
|
|
주요 개선사항:
|
|
1. ATR 기반 동적 손절/익절 + 트레일링 스탑
|
|
2. 변동성 기반 포지션 사이징 (1주 고정 → 동적 수량)
|
|
3. 보유종목 분석 기반 매도 (score 기반 SELL 판단)
|
|
4. 매크로 상태를 분석 워커에 전달 (동적 임계값)
|
|
5. 최고가 추적 (트레일링 스탑용)
|
|
6. 상세한 매매 로그 및 텔레그램 알림
|
|
"""
|
|
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None, eod_event=None):
|
|
# 1. 서비스 초기화
|
|
self.kis = KISClient()
|
|
self.news = AsyncNewsCollector()
|
|
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
|
|
|
|
self.messenger = TelegramMessenger()
|
|
self.theme_manager = ThemeManager()
|
|
self.ollama_monitor = OllamaManager()
|
|
|
|
# 2. 유틸리티 초기화
|
|
self.monitor = SystemMonitor(self.messenger, self.ollama_monitor)
|
|
|
|
# 3. 상태 변수
|
|
self.daily_trade_history = []
|
|
self.discovered_stocks = set()
|
|
self.is_macro_warning_sent = False
|
|
self.watchlist_updated_today = False
|
|
self.report_sent = False
|
|
|
|
# [v2.0] 트레일링 스탑용 최고가 추적
|
|
# {ticker: peak_price}
|
|
self.peak_prices = {}
|
|
|
|
# [v2.0] 최근 매크로 상태 캐싱
|
|
self.last_macro_status = None
|
|
|
|
# [v2.1] 연속 손절 안전장치
|
|
# 당일 손절 횟수가 임계치 초과 시 매수 일시 중단
|
|
self._consecutive_stop_losses_today = 0
|
|
self._buy_paused_until = None # datetime or None
|
|
|
|
# [v3.1] 사이클 간 당일 매수 금액 추적 (KIS T+2 미차감 문제 보완)
|
|
self._today_buy_total = 0 # 당일 누적 매수 집행 금액 (원)
|
|
self._today_buy_date = None # 날짜 리셋용
|
|
|
|
# [v3.1] 앙상블 학습용 매수 당시 신호 점수 보관 {ticker: {tech, sentiment, lstm}}
|
|
# 매도 시 실현 수익률과 함께 ensemble.record_trade()에 전달
|
|
self._buy_scores: dict = {}
|
|
|
|
# 4. 프로세스 관리
|
|
self.shutdown_event = shutdown_event
|
|
self.eod_event = eod_event # EOD 셧다운 시그널 (→ main_server 자동 종료)
|
|
self._eod_shutdown_done = False # 당일 EOD 처리 완료 여부
|
|
|
|
# KRX 캘린더 (장 운영 여부 판단)
|
|
from modules.utils.market_calendar import get_calendar
|
|
self._calendar = get_calendar()
|
|
|
|
# 5. IPC (Shared Memory)
|
|
try:
|
|
from modules.utils.ipc import SharedIPC
|
|
self.ipc = SharedIPC(lock=ipc_lock, command_queue=command_queue)
|
|
except ImportError:
|
|
print("[Bot] SharedIPC module not found.")
|
|
self.ipc = None
|
|
|
|
# 6. Watchlist Manager
|
|
try:
|
|
from watchlist_manager import WatchlistManager
|
|
self.watchlist_manager = WatchlistManager(self.kis, watchlist_file=Config.WATCHLIST_FILE)
|
|
except ImportError:
|
|
self.watchlist_manager = None
|
|
|
|
# 7. 기록 로드
|
|
self.history_file = Config.HISTORY_FILE
|
|
self.load_trade_history()
|
|
|
|
# 7-1. 성과 DB 및 평가 플래그
|
|
self.perf_db = PerformanceDB()
|
|
self.weekly_eval_sent = False
|
|
self._snapshot_taken_today = False
|
|
self._pending_evaluate = False
|
|
|
|
# 8. AI 하드웨어 점검
|
|
from modules.analysis.deep_learning import PricePredictor
|
|
PricePredictor.verify_hardware()
|
|
|
|
# 9. KIS 비동기 클라이언트
|
|
try:
|
|
from modules.services.kis import KISAsyncClient
|
|
self.kis_async = KISAsyncClient(self.kis)
|
|
except ImportError:
|
|
self.kis_async = None
|
|
|
|
def load_trade_history(self):
|
|
if os.path.exists(self.history_file):
|
|
try:
|
|
with open(self.history_file, "r", encoding="utf-8") as f:
|
|
self.daily_trade_history = json.load(f)
|
|
except Exception:
|
|
self.daily_trade_history = []
|
|
else:
|
|
self.daily_trade_history = []
|
|
|
|
def save_trade_history(self):
|
|
try:
|
|
with open(self.history_file, "w", encoding="utf-8") as f:
|
|
json.dump(self.daily_trade_history, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
print(f"[Bot] Failed to save history: {e}")
|
|
|
|
def load_watchlist(self):
|
|
try:
|
|
with open(Config.WATCHLIST_FILE, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
def _take_daily_snapshot(self, macro_status, balance):
|
|
"""일별 자산 스냅샷을 perf_db에 저장 (09:05~09:15 호출)."""
|
|
try:
|
|
total_eval_snap = int(balance.get("total_eval", 0))
|
|
deposit_snap = int(balance.get("deposit", 0))
|
|
holdings_count_snap = len([
|
|
h for h in balance.get("holdings", [])
|
|
if int(h.get("qty", 0)) > 0
|
|
])
|
|
|
|
# KOSPI 현재가 (macro_status 지표에서 추출)
|
|
kospi_close = None
|
|
try:
|
|
indicators = macro_status.get("indicators", {})
|
|
kospi_price = float(indicators.get("KOSPI", {}).get("price", 0))
|
|
if kospi_price > 0:
|
|
kospi_close = kospi_price
|
|
except Exception:
|
|
pass
|
|
|
|
self.perf_db.save_daily_snapshot(
|
|
total_eval_snap, deposit_snap, holdings_count_snap, kospi_close)
|
|
self._snapshot_taken_today = True
|
|
except Exception as e:
|
|
print(f"[Bot] Daily snapshot error: {e}")
|
|
|
|
async def _end_of_day_shutdown(self):
|
|
"""
|
|
[EOD] 장 마감 후 전체 학습 상태 저장 + 봇 프로세스 종료
|
|
|
|
저장 항목:
|
|
1. 앙상블 가중치 & 매매 히스토리 (ensemble_history.json)
|
|
2. 트레일링 스탑 최고가 (peak_prices.json)
|
|
3. 일일 거래 기록 (daily_trade_history.json)
|
|
4. 일별 자산 스냅샷 (perf_db)
|
|
5. EOD 마커 파일 (data/.eod_date → Watchdog 재시작 차단)
|
|
"""
|
|
print("[Bot] ===== EOD 상태 저장 시작 =====")
|
|
|
|
# 1. 앙상블 가중치 강제 저장
|
|
try:
|
|
ensemble = get_ensemble()
|
|
ensemble._save()
|
|
print("[Bot] [EOD] 앙상블 가중치 저장 완료")
|
|
except Exception as e:
|
|
print(f"[Bot] [EOD] 앙상블 저장 오류: {e}")
|
|
|
|
# 2. 트레일링 스탑 최고가 저장
|
|
try:
|
|
self._save_peak_prices()
|
|
print("[Bot] [EOD] 최고가 데이터 저장 완료")
|
|
except Exception as e:
|
|
print(f"[Bot] [EOD] 최고가 저장 오류: {e}")
|
|
|
|
# 3. 일일 거래 기록 저장
|
|
try:
|
|
self.save_trade_history()
|
|
print(f"[Bot] [EOD] 거래 기록 저장 완료 ({len(self.daily_trade_history)}건)")
|
|
except Exception as e:
|
|
print(f"[Bot] [EOD] 거래 기록 저장 오류: {e}")
|
|
|
|
# 4. 일별 자산 스냅샷 (미완료 시)
|
|
if not self._snapshot_taken_today:
|
|
try:
|
|
balance_snap = self.kis.get_balance()
|
|
macro_cached = self.last_macro_status or {"indicators": {}}
|
|
self._take_daily_snapshot(macro_cached, balance_snap)
|
|
print("[Bot] [EOD] 자산 스냅샷 저장 완료")
|
|
except Exception as e:
|
|
print(f"[Bot] [EOD] 스냅샷 저장 오류: {e}")
|
|
|
|
# 5. EOD 마커 파일 기록 (Watchdog 재시작 차단)
|
|
try:
|
|
from pathlib import Path
|
|
import datetime as _dt
|
|
eod_file = Path(Config.DATA_DIR) / ".eod_date"
|
|
eod_file.parent.mkdir(exist_ok=True)
|
|
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
|
|
print(f"[Bot] [EOD] 마커 파일 기록: {eod_file}")
|
|
except Exception as e:
|
|
print(f"[Bot] [EOD] 마커 파일 오류: {e}")
|
|
|
|
# 6. 텔레그램 알림
|
|
try:
|
|
today_trades = len(self.daily_trade_history)
|
|
try:
|
|
nxt = self._calendar.next_trading_open()
|
|
next_str = nxt.strftime('%m/%d(%a) %H:%M')
|
|
except Exception:
|
|
next_str = "미정"
|
|
self.messenger.send_message(
|
|
f"[장 마감] EOD 상태 저장 완료\n"
|
|
f"오늘 매매: {today_trades}건\n"
|
|
f"다음 거래일: {next_str} KST 자동 시작"
|
|
)
|
|
except Exception as e:
|
|
print(f"[Bot] [EOD] 알림 오류: {e}")
|
|
|
|
print("[Bot] ===== EOD 상태 저장 완료 =====")
|
|
|
|
# 7. 종료 시그널
|
|
if self.eod_event:
|
|
self.eod_event.set() # main_server → 서버 프로세스 자동 종료
|
|
if self.shutdown_event:
|
|
self.shutdown_event.set() # 텔레그램 봇 등 자식 프로세스 종료
|
|
|
|
async def _run_weekly_evaluation(self):
|
|
"""주간 성과 평가 실행 후 텔레그램으로 전송."""
|
|
try:
|
|
from modules.analysis.evaluator import PerformanceEvaluator
|
|
evaluator = PerformanceEvaluator()
|
|
loop = asyncio.get_running_loop()
|
|
# Ollama 호출이 동기 블로킹이므로 executor에서 실행
|
|
report = await loop.run_in_executor(None, evaluator.generate_weekly_report)
|
|
if len(report) > 4000:
|
|
report = report[:4000] + "\n... (일부 생략)"
|
|
self.messenger.send_message(report)
|
|
self.weekly_eval_sent = True
|
|
print("[Bot] Weekly evaluation report sent.")
|
|
except Exception as e:
|
|
print(f"[Bot] Weekly evaluation error: {e}")
|
|
self.messenger.send_message(f"[Bot] 주간 평가 오류: {e}")
|
|
|
|
def _load_peak_prices(self):
|
|
"""트레일링 스탑용 최고가 데이터 로드"""
|
|
peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json")
|
|
if os.path.exists(peak_file):
|
|
try:
|
|
with open(peak_file, "r", encoding="utf-8") as f:
|
|
self.peak_prices = json.load(f)
|
|
except Exception:
|
|
self.peak_prices = {}
|
|
|
|
def _save_peak_prices(self):
|
|
"""트레일링 스탑용 최고가 데이터 저장"""
|
|
peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json")
|
|
try:
|
|
with open(peak_file, "w", encoding="utf-8") as f:
|
|
json.dump(self.peak_prices, f, indent=2)
|
|
except Exception:
|
|
pass
|
|
|
|
def _update_peak_price(self, ticker, current_price):
|
|
"""보유 종목의 최고가 갱신"""
|
|
if ticker not in self.peak_prices:
|
|
self.peak_prices[ticker] = current_price
|
|
elif current_price > self.peak_prices[ticker]:
|
|
self.peak_prices[ticker] = current_price
|
|
print(f" 📈 [Peak Updated] {ticker}: {current_price:,.0f}")
|
|
|
|
def send_daily_report(self):
|
|
if self.report_sent:
|
|
return
|
|
print("[Bot] Generating Daily Report...")
|
|
balance = self.kis.get_balance()
|
|
|
|
total_eval = int(balance.get("total_eval", 0))
|
|
deposit = int(balance.get("deposit", 0))
|
|
|
|
report = (f"📅 <b>[Daily Closing Report]</b>\n"
|
|
f"💰 <b>Total Asset:</b> <code>{total_eval:,}원</code>\n"
|
|
f"💵 <b>Cash:</b> <code>{deposit:,}원</code>\n"
|
|
f"📜 <b>Trades Today:</b> <code>{len(self.daily_trade_history)}건</code>\n\n")
|
|
|
|
# 매매 내역
|
|
if self.daily_trade_history:
|
|
total_profit = 0
|
|
buy_count = 0
|
|
sell_count = 0
|
|
for trade in self.daily_trade_history:
|
|
action = trade['action']
|
|
icon = "🔴" if action == "BUY" else "🔵"
|
|
qty = trade.get('qty', 0)
|
|
price = trade.get('price', 0)
|
|
reason = trade.get('reason', '')
|
|
report += f"{icon} <b>{action}</b> {trade['name']} {qty}주 @ {price:,.0f}원"
|
|
if reason:
|
|
report += f" ({reason})"
|
|
report += "\n"
|
|
|
|
if action == "BUY":
|
|
buy_count += 1
|
|
else:
|
|
sell_count += 1
|
|
total_profit += trade.get('profit', 0)
|
|
|
|
report += f"\n📊 매수 {buy_count}건 / 매도 {sell_count}건"
|
|
if sell_count > 0:
|
|
report += f" | 실현손익: <code>{total_profit:,.0f}원</code>"
|
|
report += "\n"
|
|
|
|
# 보유종목 현황
|
|
if "holdings" in balance and balance["holdings"]:
|
|
report += "\n📊 <b>[Holdings]</b>\n"
|
|
for stock in balance["holdings"]:
|
|
yld = float(stock.get('yield', 0))
|
|
profit_loss = int(stock.get('profit_loss', 0))
|
|
if yld > 0:
|
|
icon = "🔴"
|
|
yld_str = f"+{yld}"
|
|
elif yld < 0:
|
|
icon = "🔵"
|
|
yld_str = f"{yld}"
|
|
else:
|
|
icon = "⚪"
|
|
yld_str = f"{yld}"
|
|
|
|
report += (f"{icon} {stock['name']}: <code>{yld_str}%</code> "
|
|
f"(<code>{profit_loss:+,}원</code>)\n")
|
|
|
|
self.messenger.send_message(report)
|
|
self.report_sent = True
|
|
|
|
def restart_executor(self):
|
|
print("[Bot] Restarting Process Executor...")
|
|
try:
|
|
self.executor.shutdown(wait=False)
|
|
except Exception:
|
|
pass
|
|
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
|
|
print("[Bot] Process Executor Restarted.")
|
|
|
|
def _process_commands(self):
|
|
"""IPC command queue 폴링 및 처리"""
|
|
if not self.ipc:
|
|
return
|
|
|
|
commands = self.ipc.poll_commands()
|
|
for cmd in commands:
|
|
command = cmd.get('command', '')
|
|
print(f"[Bot] Received command: {command}")
|
|
|
|
if command == 'restart':
|
|
self.messenger.send_message("[Bot] Restart requested via Telegram.")
|
|
self.restart_executor()
|
|
|
|
elif command == 'update_watchlist':
|
|
if self.watchlist_manager:
|
|
try:
|
|
summary = self.watchlist_manager.update_watchlist_daily()
|
|
self.messenger.send_message(f"[Watchlist Updated]\n{summary}")
|
|
except Exception as e:
|
|
self.messenger.send_message(f"Watchlist update failed: {e}")
|
|
|
|
elif command == 'evaluate':
|
|
self._pending_evaluate = True
|
|
|
|
async def run_cycle(self):
|
|
now = datetime.now()
|
|
|
|
# 0. 명령 큐 폴링
|
|
self._process_commands()
|
|
|
|
# 0-1. 즉시 평가 요청 처리 (IPC 'evaluate' 명령)
|
|
if self._pending_evaluate:
|
|
self._pending_evaluate = False
|
|
await self._run_weekly_evaluation()
|
|
|
|
# 1. 거시경제 분석
|
|
macro_status = MacroAnalyzer.get_macro_status(self.kis)
|
|
self.last_macro_status = macro_status
|
|
is_crash = False
|
|
|
|
if macro_status['status'] == 'DANGER':
|
|
is_crash = True
|
|
if not self.is_macro_warning_sent:
|
|
self.messenger.send_message(
|
|
"🚨 <b>[MARKET CRASH ALERT]</b>\n"
|
|
"시장 급락 감지! 매수 중단, 매도 기준 상향.\n"
|
|
f"Risk Score: {macro_status['risk_score']}")
|
|
self.is_macro_warning_sent = True
|
|
elif macro_status['status'] == 'CAUTION':
|
|
if not self.is_macro_warning_sent:
|
|
self.messenger.send_message(
|
|
"⚠️ <b>[MARKET CAUTION]</b>\n"
|
|
"시장 불안정. 보수적 매매 모드 전환.\n"
|
|
f"Risk Score: {macro_status['risk_score']}")
|
|
self.is_macro_warning_sent = True
|
|
else:
|
|
if self.is_macro_warning_sent:
|
|
self.messenger.send_message("🌤️ <b>[MARKET RECOVERY]</b> 시장 안정화.")
|
|
self.is_macro_warning_sent = False
|
|
|
|
# 2. IPC 상태 업데이트
|
|
if self.ipc:
|
|
try:
|
|
balance = self.kis.get_balance()
|
|
gpu_status = self.ollama_monitor.get_gpu_status()
|
|
watchlist = self.load_watchlist()
|
|
self.ipc.write_status({
|
|
'balance': balance,
|
|
'gpu': gpu_status,
|
|
'watchlist': watchlist,
|
|
'discovered_stocks': list(self.discovered_stocks),
|
|
'is_macro_warning': self.is_macro_warning_sent,
|
|
'macro_indices': macro_status['indicators'],
|
|
'themes': {}
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# 3. 아침 업데이트 (08:00)
|
|
if now.hour == 8 and 0 <= now.minute < 5:
|
|
if not self.watchlist_updated_today and self.watchlist_manager:
|
|
print("[Bot] Morning Update...")
|
|
try:
|
|
summary = self.watchlist_manager.update_watchlist_daily()
|
|
self.messenger.send_message(summary)
|
|
self.watchlist_updated_today = True
|
|
except Exception as e:
|
|
self.messenger.send_message(f"Update Failed: {e}")
|
|
|
|
# 4. 리셋 (09:00)
|
|
if now.hour == 9 and now.minute < 5:
|
|
self.daily_trade_history = []
|
|
self.save_trade_history()
|
|
self.report_sent = False
|
|
self.weekly_eval_sent = False
|
|
self._snapshot_taken_today = False
|
|
self.discovered_stocks.clear()
|
|
self.watchlist_updated_today = False
|
|
# 전일 최고가 초기화 (보유하지 않는 종목)
|
|
self._load_peak_prices()
|
|
# [v3.1] 당일 매수 추적 리셋
|
|
self._today_buy_total = 0
|
|
self._today_buy_date = now.date()
|
|
self._buy_scores.clear() # 미매도 종목 신호 점수도 초기화
|
|
print(f"[Bot] 일일 매수 추적 리셋 (날짜: {now.date()})")
|
|
|
|
# 5. 시스템 감시 (3분 간격)
|
|
self.monitor.check_health()
|
|
|
|
# 6. 장 운영 시간 체크
|
|
if not (9 <= now.hour < 15 or (now.hour == 15 and now.minute < 30)):
|
|
if now.hour == 15 and now.minute >= 40:
|
|
self.send_daily_report()
|
|
# 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록)
|
|
if now.hour == 16 and now.minute <= 30 and not self._snapshot_taken_today:
|
|
try:
|
|
balance_snap = self.kis.get_balance()
|
|
self._take_daily_snapshot(macro_status, balance_snap)
|
|
except Exception as e:
|
|
print(f"[Bot] Snapshot error: {e}")
|
|
# 주간 평가 (금요일 15:35~15:45, 장 마감 직후)
|
|
if (now.weekday() == 4 and now.hour == 15
|
|
and 35 <= now.minute <= 45 and not self.weekly_eval_sent):
|
|
await self._run_weekly_evaluation()
|
|
|
|
# [EOD 셧다운] 장 마감 후 Config.EOD_SHUTDOWN_BUFFER_MIN 분 경과 시 저장 후 종료
|
|
eod_buffer = now.hour == 15 and now.minute >= (30 + Config.EOD_SHUTDOWN_BUFFER_MIN)
|
|
eod_buffer = eod_buffer or (now.hour >= 16) # 16시 이후도 포함
|
|
if eod_buffer and not self._eod_shutdown_done:
|
|
self._eod_shutdown_done = True
|
|
await self._end_of_day_shutdown()
|
|
return
|
|
|
|
# 장 외 시간에는 서킷 브레이커도 리셋
|
|
self.monitor.reset_circuit()
|
|
if not self._eod_shutdown_done:
|
|
print("[Bot] Market Closed. Waiting...")
|
|
return
|
|
|
|
# [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단
|
|
if self.monitor.is_cpu_critical():
|
|
print("[Bot] ⛔ CPU Circuit Breaker 발동 중. 분석 사이클 스킵.")
|
|
return
|
|
|
|
cycle_start_time = time.time()
|
|
print(f"[Bot] Cycle Start: {now.strftime('%H:%M:%S')}")
|
|
|
|
# 7. 종목 분석 및 매매
|
|
target_dict = self.load_watchlist()
|
|
|
|
# [v2.0] 잔고 조회 및 보유종목 맵 생성
|
|
balance = self.kis.get_balance()
|
|
current_holdings = {}
|
|
total_eval = int(balance.get("total_eval", 0))
|
|
|
|
if balance and "holdings" in balance:
|
|
for stock in balance["holdings"]:
|
|
code = stock.get("code")
|
|
qty = int(stock.get("qty", 0))
|
|
if qty > 0:
|
|
current_holdings[code] = stock
|
|
# 최고가 업데이트 (트레일링 스탑용)
|
|
current_price = float(stock.get('current_price', 0))
|
|
if current_price > 0:
|
|
self._update_peak_price(code, current_price)
|
|
|
|
# [v2.0] 보유종목도 분석 대상에 포함 (watchlist에 없어도)
|
|
for code in current_holdings:
|
|
if code not in target_dict:
|
|
name = current_holdings[code].get('name', 'Unknown')
|
|
target_dict[code] = name
|
|
print(f"[Bot] Added holding to analysis: {name} ({code})")
|
|
|
|
# 분석 실행 (병렬 처리)
|
|
analysis_tasks = []
|
|
news_data = await self.news.get_market_news_async()
|
|
|
|
raw_deposit = int(balance.get("deposit", 0))
|
|
|
|
# [v3.1] 사이클 간 누적 매수금액 추적 (KIS 모의투자 T+2 미차감 보완)
|
|
# KIS API의 dnca_tot_amt(예수금)는 당일 매수를 즉시 차감하지 않아
|
|
# 매 사이클마다 전체 잔고처럼 보이는 문제를 방지
|
|
today = now.date()
|
|
if self._today_buy_date != today:
|
|
# 날짜 변경 시 리셋 (09:00 리셋 블록에서 이미 처리되지만 안전망으로 이중 체크)
|
|
self._today_buy_total = 0
|
|
self._today_buy_date = today
|
|
|
|
# KIS가 제공하는 금일매수금액이 있으면 그것을 우선 사용 (더 정확)
|
|
kis_today_buy = int(balance.get("today_buy_amt", 0))
|
|
if kis_today_buy > 0:
|
|
# KIS 값이 유효하면 로컬 추적값과 최댓값으로 사용 (둘 다 참조)
|
|
effective_today_buy = max(kis_today_buy, self._today_buy_total)
|
|
else:
|
|
effective_today_buy = self._today_buy_total
|
|
|
|
# 실제 사용 가능한 예수금 = KIS 예수금 - 당일 이미 집행한 매수금액
|
|
max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO)
|
|
tracking_deposit = max(0, min(raw_deposit, max_daily_buy) - effective_today_buy)
|
|
|
|
print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | "
|
|
f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)")
|
|
|
|
# [v3.0] 비동기 OHLCV + 투자자 동향 배치 조회
|
|
tickers_list = list(target_dict.keys())
|
|
ohlcv_batch = {}
|
|
investor_batch = {}
|
|
|
|
if self.kis_async and tickers_list:
|
|
try:
|
|
print(f"[Bot] 비동기 OHLCV 배치 조회: {len(tickers_list)}종목")
|
|
ohlcv_batch = await self.kis_async.get_daily_ohlcv_batch(tickers_list)
|
|
investor_batch = await self.kis_async.get_investor_trends_batch(tickers_list)
|
|
except Exception as e:
|
|
print(f"[Bot] 비동기 배치 조회 실패: {e} -> 동기 fallback")
|
|
ohlcv_batch = {}
|
|
investor_batch = {}
|
|
|
|
# [v3.1] 사이클당 매수 횟수 제한
|
|
buys_this_cycle = 0
|
|
|
|
try:
|
|
for ticker, name in target_dict.items():
|
|
# OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback)
|
|
ohlcv_data = ohlcv_batch.get(ticker)
|
|
if not ohlcv_data or not ohlcv_data.get('close'):
|
|
ohlcv_data = self.kis.get_daily_ohlcv(ticker)
|
|
if not ohlcv_data or not ohlcv_data.get('close'):
|
|
continue
|
|
|
|
# [v2.0] 보유 정보 전달 (분석 워커에서 동적 손절/익절 사용)
|
|
holding_info = None
|
|
if ticker in current_holdings:
|
|
h = current_holdings[ticker]
|
|
holding_info = {
|
|
'qty': int(h.get('qty', 0)),
|
|
'yield': float(h.get('yield', 0.0)),
|
|
'purchase_price': float(h.get('purchase_price', 0)),
|
|
'current_price': float(h.get('current_price', 0)),
|
|
'peak_price': self.peak_prices.get(ticker, float(h.get('current_price', 0)))
|
|
}
|
|
|
|
# investor_trend fallback
|
|
investor_trend = investor_batch.get(ticker)
|
|
if investor_trend is None:
|
|
investor_trend = self.kis.get_investor_trend(ticker)
|
|
|
|
future = self.executor.submit(
|
|
analyze_stock_process, ticker, ohlcv_data, news_data,
|
|
investor_trend, macro_status, holding_info,
|
|
total_eval if total_eval > 0 else None)
|
|
analysis_tasks.append(future)
|
|
|
|
# 결과 처리
|
|
loop = asyncio.get_running_loop()
|
|
for future in analysis_tasks:
|
|
try:
|
|
# 240초 타임아웃: LSTM 학습 + Ollama 추론 시간 고려
|
|
res = await loop.run_in_executor(None, lambda f=future: f.result(240))
|
|
ticker = res['ticker']
|
|
ticker_name = target_dict.get(ticker, 'Unknown')
|
|
print(f"[Bot] [{ticker_name}] Score: {res['score']:.2f} ({res['decision']})"
|
|
f" | SL:{res.get('sl_tp', {}).get('stop_loss_pct', 'N/A')}%"
|
|
f" TP:{res.get('sl_tp', {}).get('take_profit_pct', 'N/A')}%")
|
|
|
|
# ===== 매수 처리 =====
|
|
if res['decision'] == "BUY":
|
|
if is_crash:
|
|
print(f"[Bot] [Skip Buy] Market DANGER mode - {ticker_name}")
|
|
continue
|
|
|
|
# [v3.1] 사이클당 최대 매수 종목 수 제한
|
|
if buys_this_cycle >= Config.MAX_BUY_PER_CYCLE:
|
|
print(f"[Bot] [Skip Buy] 사이클 최대 매수 횟수 초과 "
|
|
f"({buys_this_cycle}/{Config.MAX_BUY_PER_CYCLE}) - {ticker_name}")
|
|
continue
|
|
|
|
# [v2.1] 연속 손절 후 매수 일시 중단 체크
|
|
if self._buy_paused_until and datetime.now() < self._buy_paused_until:
|
|
print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: "
|
|
f"{self._buy_paused_until.strftime('%H:%M')}) - {ticker_name}")
|
|
continue
|
|
elif self._buy_paused_until and datetime.now() >= self._buy_paused_until:
|
|
self._buy_paused_until = None
|
|
self._consecutive_stop_losses_today = 0
|
|
print("[Bot] 매수 일시 중단 해제")
|
|
|
|
current_price = float(res['current_price'])
|
|
if current_price <= 0:
|
|
continue
|
|
|
|
# [v3.1] 워커에서 Kelly Criterion으로 계산한 수량 직접 사용
|
|
# (중복 계산 제거 — 워커가 total_eval 기준으로 이미 계산 완료)
|
|
qty = res.get('suggested_qty', 0)
|
|
if qty <= 0:
|
|
print(f"[Bot] [Skip Buy] Position size = 0 ({ticker_name})")
|
|
continue
|
|
|
|
required_amount = current_price * qty
|
|
|
|
# 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액)
|
|
if tracking_deposit < required_amount:
|
|
qty = int(tracking_deposit / current_price)
|
|
if qty <= 0:
|
|
print(f"[Bot] [Skip Buy] 예수금 부족 ({ticker_name}): "
|
|
f"필요 {required_amount:,.0f} > 가용 {tracking_deposit:,.0f}")
|
|
continue
|
|
required_amount = current_price * qty
|
|
|
|
print(f"[Bot] Buying {ticker_name} {qty}ea @ ~{current_price:,.0f}")
|
|
order = self.kis.buy_stock(ticker, qty)
|
|
if order.get("status"):
|
|
reason = res.get('decision_reason', '')
|
|
sl_tp = res.get('sl_tp', {})
|
|
|
|
msg = (f"🔴 <b>[BUY]</b> {ticker_name} {qty}주\n"
|
|
f" Price: <code>{current_price:,.0f}원</code>\n"
|
|
f" Score: <code>{res['score']:.2f}</code>\n"
|
|
f" SL: <code>{sl_tp.get('stop_loss_pct', -5):.1f}%</code>"
|
|
f" | TP: <code>{sl_tp.get('take_profit_pct', 8):.1f}%</code>"
|
|
f" | Trail: <code>{sl_tp.get('trailing_stop_pct', 3):.1f}%</code>")
|
|
if reason:
|
|
msg += f"\n Reason: {reason}"
|
|
|
|
self.messenger.send_message(msg)
|
|
self.daily_trade_history.append({
|
|
"action": "BUY", "name": ticker_name,
|
|
"qty": qty, "price": current_price,
|
|
"score": res['score'],
|
|
"reason": reason
|
|
})
|
|
self.save_trade_history()
|
|
|
|
# 성과 DB 기록
|
|
pred = res.get("prediction") or {}
|
|
self.perf_db.save_trade_record(
|
|
action="BUY", ticker=ticker, name=ticker_name,
|
|
qty=qty, price=current_price,
|
|
scores_dict={
|
|
"tech": res.get("tech", 0.0),
|
|
"sentiment": res.get("sentiment", 0.0),
|
|
"lstm_score": res.get("lstm_score", 0.0),
|
|
"score": res.get("score", 0.0),
|
|
"ai_confidence": res.get("ai_confidence", 0.5),
|
|
"prediction_change": pred.get("change_rate", 0.0)
|
|
},
|
|
reason=reason,
|
|
macro_state=macro_status.get("status", "SAFE")
|
|
)
|
|
|
|
tracking_deposit -= required_amount
|
|
# [v3.1] 사이클 간 추적 (KIS T+2 미차감 보완)
|
|
self._today_buy_total += required_amount
|
|
buys_this_cycle += 1
|
|
print(f"[Bot] 당일 누적 매수: {self._today_buy_total:,}원 "
|
|
f"(잔여 예수금: {tracking_deposit:,}원)")
|
|
|
|
# [v3.1] 앙상블 학습용 매수 신호 점수 보관 (매도 시 record_trade에 활용)
|
|
self._buy_scores[ticker] = {
|
|
"tech": res.get("tech", 0.5),
|
|
"sentiment": res.get("sentiment", 0.5),
|
|
"lstm": res.get("lstm_score", 0.5),
|
|
}
|
|
|
|
# 최고가 초기 설정
|
|
self.peak_prices[ticker] = current_price
|
|
self._save_peak_prices()
|
|
|
|
# ===== 매도 처리 (v2.1 - 연속 손절 안전장치 포함) =====
|
|
elif res['decision'] == "SELL" and ticker in current_holdings:
|
|
h = current_holdings[ticker]
|
|
qty = int(h.get('qty', 0))
|
|
yld = float(h.get('yield', 0.0))
|
|
profit_loss = int(h.get('profit_loss', 0))
|
|
|
|
if qty > 0:
|
|
print(f"[Bot] Selling {ticker_name} {qty}ea (Yield: {yld:.1f}%)")
|
|
sell_res = self.kis.sell_stock(ticker, qty)
|
|
|
|
if sell_res and sell_res.get("status"):
|
|
reason = res.get('decision_reason', 'AI Signal')
|
|
|
|
msg = (f"🔵 <b>[SELL]</b> {ticker_name} {qty}주\n"
|
|
f" Yield: <code>{yld:.1f}%</code>\n"
|
|
f" P&L: <code>{profit_loss:+,}원</code>\n"
|
|
f" Reason: {reason}")
|
|
|
|
self.messenger.send_message(msg)
|
|
sell_price = float(h.get('current_price', 0))
|
|
self.daily_trade_history.append({
|
|
"action": "SELL", "name": ticker_name,
|
|
"qty": qty, "price": sell_price,
|
|
"yield": yld, "profit": profit_loss,
|
|
"reason": reason
|
|
})
|
|
self.save_trade_history()
|
|
|
|
# 성과 DB 매도 결과 기록
|
|
self.perf_db.close_trade(ticker, sell_price, yld)
|
|
|
|
# [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률)
|
|
buy_sig = self._buy_scores.pop(ticker, None)
|
|
if buy_sig is not None:
|
|
try:
|
|
get_ensemble().record_trade(
|
|
ticker=ticker,
|
|
tech_score=buy_sig["tech"],
|
|
sentiment_score=buy_sig["sentiment"],
|
|
lstm_score=buy_sig["lstm"],
|
|
decision="BUY",
|
|
outcome_pct=yld
|
|
)
|
|
print(f"[Bot] [Ensemble] {ticker_name} 학습 기록: "
|
|
f"outcome={yld:+.1f}%")
|
|
except Exception as _ee:
|
|
print(f"[Bot] [Ensemble] record_trade 실패: {_ee}")
|
|
|
|
# [v2.1] 손절 횟수 추적 → 연속 3회 손절 시 매수 30분 일시 중단
|
|
if yld < 0:
|
|
self._consecutive_stop_losses_today += 1
|
|
if self._consecutive_stop_losses_today >= 3:
|
|
self._buy_paused_until = datetime.now() + timedelta(minutes=30)
|
|
warn_msg = (
|
|
f"⛔ <b>[매수 일시 중단]</b> 당일 손절 "
|
|
f"{self._consecutive_stop_losses_today}회 → "
|
|
f"30분간 매수 정지 (재개: "
|
|
f"{self._buy_paused_until.strftime('%H:%M')})"
|
|
)
|
|
self.messenger.send_message(warn_msg)
|
|
print(f"[Bot] 연속 손절 {self._consecutive_stop_losses_today}회 → 매수 30분 중단")
|
|
else:
|
|
# 수익 실현 시 연속 손절 카운터 리셋
|
|
self._consecutive_stop_losses_today = 0
|
|
|
|
# 최고가 기록 삭제
|
|
if ticker in self.peak_prices:
|
|
del self.peak_prices[ticker]
|
|
self._save_peak_prices()
|
|
|
|
except BrokenProcessPool:
|
|
raise
|
|
except Exception as e:
|
|
print(f"[Bot] Analysis Worker Error: {e}")
|
|
|
|
except BrokenProcessPool:
|
|
print("[Bot] Worker Process Crashed. Restarting Executor...")
|
|
self.restart_executor()
|
|
except KeyboardInterrupt:
|
|
raise
|
|
except Exception as e:
|
|
print(f"[Bot] Cycle Loop Error: {e}")
|
|
|
|
# 사이클 소요시간 로깅 (120초 초과 시 경고)
|
|
cycle_elapsed = time.time() - cycle_start_time
|
|
if cycle_elapsed > 120:
|
|
print(f"[Bot] ⚠️ 사이클 소요 {cycle_elapsed:.0f}초 (120초 초과) → LSTM 쿨다운 활성화 권장")
|
|
else:
|
|
print(f"[Bot] Cycle Done: {cycle_elapsed:.1f}초")
|
|
|
|
def loop(self):
|
|
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]")
|
|
|
|
# [캘린더 체크] 오늘이 휴장일이면 알림 후 즉시 EOD 종료
|
|
if not self._calendar.is_trading_day():
|
|
summary = self._calendar.status_summary()
|
|
print(f"[Bot] 오늘은 휴장일 ({summary}) — 봇을 시작하지 않습니다.")
|
|
self.messenger.send_message(
|
|
f"[Bot] 오늘은 휴장일입니다.\n{summary}"
|
|
)
|
|
# EOD 마커 기록 후 종료
|
|
try:
|
|
from pathlib import Path
|
|
import datetime as _dt
|
|
eod_file = Path(Config.DATA_DIR) / ".eod_date"
|
|
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
if self.eod_event:
|
|
self.eod_event.set()
|
|
if self.shutdown_event:
|
|
self.shutdown_event.set()
|
|
return
|
|
|
|
_llm_label = (
|
|
f"Gemini ({Config.GEMINI_MODEL})"
|
|
if Config.GEMINI_API_KEY
|
|
else f"Ollama ({Config.OLLAMA_MODEL})"
|
|
)
|
|
self.messenger.send_message(
|
|
"🚀 <b>[Bot Started v3.1]</b>\n"
|
|
f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}분\n"
|
|
f"✅ LLM 엔진: {_llm_label}\n"
|
|
f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n"
|
|
f"✅ 장 상태: {self._calendar.status_summary()}\n"
|
|
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
|
|
|
|
# 최고가 데이터 로드
|
|
self._load_peak_prices()
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
try:
|
|
while True:
|
|
if self.shutdown_event and self.shutdown_event.is_set():
|
|
print("[Bot] Shutdown signal received.")
|
|
break
|
|
|
|
try:
|
|
loop.run_until_complete(self.run_cycle())
|
|
except Exception as e:
|
|
print(f"[Bot] Loop Error: {e}")
|
|
self.messenger.send_message(f"[Bot] Loop Error: {e}")
|
|
|
|
for _ in range(60):
|
|
if self.shutdown_event and self.shutdown_event.is_set():
|
|
break
|
|
time.sleep(1)
|
|
|
|
except KeyboardInterrupt:
|
|
print("[Bot] Stopped by User.")
|
|
finally:
|
|
print("[Bot] Shutting down executor...")
|
|
self.executor.shutdown(wait=False)
|
|
if self.ipc:
|
|
self.ipc.cleanup()
|
|
loop.close()
|
|
print("[Bot] Executor shutdown complete.")
|