v3.1 과매수 방지, 앙상블 학습, KRX 캘린더 기반 장중 전용 운영 구현

[잔고 관리]
- _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>
This commit is contained in:
2026-03-29 05:21:23 +09:00
parent 760d1906ed
commit 0aebca7ff0
17 changed files with 3816 additions and 200 deletions

View File

@@ -4,7 +4,7 @@ import json
import time
from concurrent.futures import ProcessPoolExecutor
from concurrent.futures.process import BrokenProcessPool
from datetime import datetime
from datetime import datetime, timedelta
from modules.config import Config
from modules.services.kis import KISClient
@@ -15,6 +15,7 @@ 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
@@ -43,7 +44,7 @@ class AutoTradingBot:
5. 최고가 추적 (트레일링 스탑용)
6. 상세한 매매 로그 및 텔레그램 알림
"""
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None):
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None, eod_event=None):
# 1. 서비스 초기화
self.kis = KISClient()
self.news = AsyncNewsCollector()
@@ -70,8 +71,27 @@ class AutoTradingBot:
# [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:
@@ -159,6 +179,86 @@ class AutoTradingBot:
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:
@@ -376,6 +476,11 @@ class AutoTradingBot:
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()
@@ -395,9 +500,19 @@ class AutoTradingBot:
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()
print("[Bot] Market Closed. Waiting...")
if not self._eod_shutdown_done:
print("[Bot] Market Closed. Waiting...")
return
# [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단
@@ -438,7 +553,31 @@ class AutoTradingBot:
analysis_tasks = []
news_data = await self.news.get_market_news_async()
tracking_deposit = int(balance.get("deposit", 0))
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())
@@ -455,6 +594,9 @@ class AutoTradingBot:
ohlcv_batch = {}
investor_batch = {}
# [v3.1] 사이클당 매수 횟수 제한
buys_this_cycle = 0
try:
for ticker, name in target_dict.items():
# OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback)
@@ -483,7 +625,8 @@ class AutoTradingBot:
future = self.executor.submit(
analyze_stock_process, ticker, ohlcv_data, news_data,
investor_trend, macro_status, holding_info)
investor_trend, macro_status, holding_info,
total_eval if total_eval > 0 else None)
analysis_tasks.append(future)
# 결과 처리
@@ -504,31 +647,41 @@ class AutoTradingBot:
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
# [v2.0] 포지션 사이징 (동적 수량)
qty = calculate_position_size(
total_capital=total_eval if total_eval > 0 else tracking_deposit,
current_price=current_price,
volatility=res.get('volatility', 2.0),
score=res['score'],
ai_confidence=res.get('ai_confidence', 0.5)
)
# [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}")
f"필요 {required_amount:,.0f} > 가용 {tracking_deposit:,.0f}")
continue
required_amount = current_price * qty
@@ -574,12 +727,24 @@ class AutoTradingBot:
)
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.0 - 분석 기반 매도) =====
# ===== 매도 처리 (v2.1 - 연속 손절 안전장치 포함) =====
elif res['decision'] == "SELL" and ticker in current_holdings:
h = current_holdings[ticker]
qty = int(h.get('qty', 0))
@@ -611,6 +776,40 @@ class AutoTradingBot:
# 성과 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]
@@ -637,12 +836,40 @@ class AutoTradingBot:
print(f"[Bot] Cycle Done: {cycle_elapsed:.1f}")
def loop(self):
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.0]")
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.0]</b>\n"
"🚀 <b>[Bot Started v3.1]</b>\n"
f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}\n"
f"AI 모델: {Config.OLLAMA_MODEL}\n"
f"LLM 엔진: {_llm_label}\n"
f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n"
f"✅ 장 상태: {self._calendar.status_summary()}\n"
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
# 최고가 데이터 로드