매매 성과 평가지표 시스템 구현

- modules/utils/performance_db.py 신규: 일별 자산 스냅샷(16:00~16:30) 및
  매매 기록 영구 저장 (PerformanceDB 클래스)
- modules/analysis/evaluator.py 신규: Sharpe/Sortino/MDD/Alpha 등 16개 지표 산출,
  S~F 등급 시스템, Ollama 5명 전문가 패널, 텔레그램 HTML 주간 보고서 (PerformanceEvaluator 클래스)
- modules/bot.py 수정: BUY/SELL 시 perf_db 기록 강화, 금요일 15:35 주간 평가 자동 실행,
  IPC 'evaluate' 명령 처리
- modules/services/telegram_bot/server.py 수정: /evaluate 명령어 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:07:34 +09:00
parent 4d41405ac4
commit 37f6d87bec
4 changed files with 872 additions and 41 deletions

View File

@@ -13,6 +13,7 @@ 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
try:
@@ -47,10 +48,6 @@ class AutoTradingBot:
self.kis = KISClient()
self.news = AsyncNewsCollector()
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
try:
list(self.executor.map(lambda x: x, range(1)))
except Exception:
pass
self.messenger = TelegramMessenger()
self.theme_manager = ThemeManager()
@@ -95,6 +92,12 @@ class AutoTradingBot:
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()
@@ -130,6 +133,49 @@ class AutoTradingBot:
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 _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")
@@ -251,12 +297,20 @@ class AutoTradingBot:
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
@@ -316,6 +370,8 @@ class AutoTradingBot:
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
# 전일 최고가 초기화 (보유하지 않는 종목)
@@ -328,9 +384,28 @@ class AutoTradingBot:
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()
# 장 외 시간에는 서킷 브레이커도 리셋
self.monitor.reset_circuit()
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. 종목 분석 및 매매
@@ -365,14 +440,30 @@ class AutoTradingBot:
tracking_deposit = int(balance.get("deposit", 0))
# [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 = {}
try:
for ticker, name in target_dict.items():
prices = self.kis.get_daily_price(ticker)
if not prices:
# 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
investor_trend = self.kis.get_investor_trend(ticker)
# [v2.0] 보유 정보 전달 (분석 워커에서 동적 손절/익절 사용)
holding_info = None
if ticker in current_holdings:
@@ -385,16 +476,22 @@ class AutoTradingBot:
'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, prices, news_data,
analyze_stock_process, ticker, ohlcv_data, news_data,
investor_trend, macro_status, holding_info)
analysis_tasks.append(future)
# 결과 처리
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
for future in analysis_tasks:
try:
res = await loop.run_in_executor(None, future.result)
# 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']})"
@@ -458,6 +555,24 @@ class AutoTradingBot:
"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
# 최고가 초기 설정
@@ -484,14 +599,18 @@ class AutoTradingBot:
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": float(h.get('current_price', 0)),
"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)
# 최고가 기록 삭제
if ticker in self.peak_prices:
del self.peak_prices[ticker]
@@ -510,11 +629,21 @@ class AutoTradingBot:
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()}) [v2.0]")
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.0]")
self.messenger.send_message(
"🚀 [Bot Started v2.0]\n"
"개선사항: 동적 손절/익절, 트레일링 스탑, 포지션 사이징, 분석 기반 매도")
"🚀 <b>[Bot Started v3.0]</b>\n"
f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}\n"
f"✅ AI 모델: {Config.OLLAMA_MODEL}\n"
f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n"
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
# 최고가 데이터 로드
self._load_peak_prices()