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

- 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

@@ -0,0 +1,421 @@
"""
성과 평가 엔진 - PerformanceEvaluator
기능:
1. compute_metrics() - 핵심 성과 지표 계산
2. get_grade() - 지표별 S/A/B/C/D/F 등급 산출
3. generate_expert_panel() - Ollama LLM 5명 전문가 의견
4. generate_weekly_report() - 텔레그램 HTML 주간 보고서
"""
import json
import math
from datetime import datetime, timedelta
from modules.utils.performance_db import PerformanceDB
class PerformanceEvaluator:
def __init__(self):
self.perf_db = PerformanceDB()
# ─────────────────────────────────────────
# 1. 핵심 지표 계산
# ─────────────────────────────────────────
def compute_metrics(self, snapshots, trades):
"""성과 지표를 딕셔너리로 반환.
Args:
snapshots (list): daily_snapshots 리스트
trades (list): trade_records 리스트
Returns:
dict: 지표 딕셔너리 (또는 {"error": ...})
"""
if not snapshots:
return {"error": "스냅샷 데이터 없음 (운영 시작 후 첫 영업일까지 대기)"}
metrics = {}
# ── 수익률 ──────────────────────────────
initial = snapshots[0].get("total_eval", 0)
current = snapshots[-1].get("total_eval", 0)
metrics["total_return_pct"] = round(
(current - initial) / initial * 100, 2) if initial > 0 else 0.0
cutoff_7 = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
recent_snaps = [s for s in snapshots if s.get("date", "") >= cutoff_7]
if len(recent_snaps) >= 2:
w_init = recent_snaps[0].get("total_eval", 0)
w_curr = recent_snaps[-1].get("total_eval", 0)
metrics["weekly_return_pct"] = round(
(w_curr - w_init) / w_init * 100, 2) if w_init > 0 else 0.0
else:
metrics["weekly_return_pct"] = 0.0
# ── 리스크 지표 ──────────────────────────
daily_returns = [s.get("daily_return_pct", 0.0) / 100.0 for s in snapshots]
if len(daily_returns) >= 2:
mean_daily = sum(daily_returns) / len(daily_returns)
variance = sum((r - mean_daily) ** 2 for r in daily_returns) / len(daily_returns)
std_daily = math.sqrt(variance) if variance > 0 else 0.0
annual_return = mean_daily * 252
# Sharpe Ratio
if std_daily > 0:
metrics["sharpe_ratio"] = round(
annual_return / (std_daily * math.sqrt(252)), 3)
else:
metrics["sharpe_ratio"] = 0.0
# Sortino Ratio (하방 편차만 사용)
downside = [r for r in daily_returns if r < 0]
if downside:
dv = sum(r ** 2 for r in downside) / len(downside)
ds = math.sqrt(dv)
metrics["sortino_ratio"] = round(
annual_return / (ds * math.sqrt(252)), 3) if ds > 0 else 0.0
else:
metrics["sortino_ratio"] = 10.0 # 손실 없음
# Max Drawdown
peak = snapshots[0].get("total_eval", 0)
max_dd = 0.0
for snap in snapshots:
ev = snap.get("total_eval", 0)
if ev > peak:
peak = ev
if peak > 0:
dd = (peak - ev) / peak * 100
if dd > max_dd:
max_dd = dd
metrics["max_drawdown_pct"] = round(max_dd, 2)
# Calmar Ratio
ann_pct = annual_return * 100
metrics["calmar_ratio"] = round(
ann_pct / max_dd, 3) if max_dd > 0 else 0.0
else:
metrics["sharpe_ratio"] = 0.0
metrics["sortino_ratio"] = 0.0
metrics["max_drawdown_pct"] = 0.0
metrics["calmar_ratio"] = 0.0
# ── 매매 지표 ─────────────────────────────
closed = [t for t in trades
if t.get("action") == "BUY" and t.get("outcome_return_pct") is not None]
if closed:
wins = [t for t in closed if t.get("outcome_return_pct", 0) > 0]
losses = [t for t in closed if t.get("outcome_return_pct", 0) <= 0]
metrics["win_rate_pct"] = round(
len(wins) / len(closed) * 100, 1)
total_profit = sum(t["outcome_return_pct"] for t in wins)
total_loss = abs(sum(t["outcome_return_pct"] for t in losses))
metrics["profit_factor"] = round(
total_profit / total_loss, 3) if total_loss > 0 else 10.0
hd_list = [t["holding_days"] for t in closed if t.get("holding_days") is not None]
metrics["avg_holding_days"] = round(
sum(hd_list) / len(hd_list), 1) if hd_list else 0.0
else:
metrics["win_rate_pct"] = 0.0
metrics["profit_factor"] = 0.0
metrics["avg_holding_days"] = 0.0
metrics["total_trades"] = len(closed)
# ── 벤치마크 Alpha ────────────────────────
kospi_vals = [s.get("benchmark_kospi_close") for s in snapshots]
kospi_valid = [k for k in kospi_vals if k is not None]
if len(kospi_valid) >= 2:
kospi_ret = (kospi_valid[-1] - kospi_valid[0]) / kospi_valid[0] * 100
metrics["alpha"] = round(metrics["total_return_pct"] - kospi_ret, 2)
metrics["kospi_return_pct"] = round(kospi_ret, 2)
else:
metrics["alpha"] = 0.0
metrics["kospi_return_pct"] = 0.0
# ── AI 품질 지표 ──────────────────────────
if closed:
# LSTM 방향 정확도
correct = 0
direction_n = 0
for t in closed:
pred = t.get("ai_prediction_change")
outcome = t.get("outcome_return_pct")
if pred is not None and outcome is not None:
if (pred > 0) == (outcome > 0):
correct += 1
direction_n += 1
metrics["lstm_direction_accuracy"] = round(
correct / direction_n * 100, 1) if direction_n > 0 else 0.0
# 신호별 수익 상관도
outcomes = [t.get("outcome_return_pct", 0) for t in closed]
def pearson(xs, ys):
n = len(xs)
if n < 2:
return 0.0
mx = sum(xs) / n
my = sum(ys) / n
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
denom_x = sum((x - mx) ** 2 for x in xs)
denom_y = sum((y - my) ** 2 for y in ys)
denom = math.sqrt(denom_x * denom_y)
return num / denom if denom > 0 else 0.0
corr_tech = pearson([t.get("tech_score", 0) for t in closed], outcomes)
corr_sent = pearson([t.get("sentiment_score", 0) for t in closed], outcomes)
corr_lstm = pearson([t.get("lstm_score", 0) for t in closed], outcomes)
metrics["signal_correlation"] = {
"tech": round(corr_tech, 3),
"sentiment": round(corr_sent, 3),
"lstm": round(corr_lstm, 3)
}
metrics["best_signal_source"] = max(
["tech", "sentiment", "lstm"],
key=lambda k: abs(metrics["signal_correlation"][k])
)
else:
metrics["lstm_direction_accuracy"] = 0.0
metrics["signal_correlation"] = {"tech": 0.0, "sentiment": 0.0, "lstm": 0.0}
metrics["best_signal_source"] = "unknown"
metrics["snapshot_count"] = len(snapshots)
return metrics
# ─────────────────────────────────────────
# 2. 등급 산출
# ─────────────────────────────────────────
def get_grade(self, metric, value):
"""지표 이름과 값으로 S/A/B/C/D/F 등급 반환."""
# MDD는 낮을수록 좋음
if metric == "max_drawdown_pct":
thresholds = [(5, "S"), (10, "A"), (15, "B"), (20, "C"), (30, "D")]
for threshold, grade in thresholds:
if value < threshold:
return grade
return "F"
grade_rules = {
"sharpe_ratio": [(2.0, "S"), (1.5, "A"), (1.0, "B"), (0.5, "C"), (0.0, "D")],
"sortino_ratio": [(3.0, "S"), (2.0, "A"), (1.5, "B"), (1.0, "C"), (0.0, "D")],
"win_rate_pct": [(70, "S"), (60, "A"), (50, "B"), (40, "C"), (30, "D")],
"profit_factor": [(3.0, "S"), (2.0, "A"), (1.5, "B"), (1.0, "C"), (0.5, "D")],
"alpha": [(15, "S"), (10, "A"), (5, "B"), (0, "C"), (-5, "D")],
"total_return_pct": [(30, "S"), (20, "A"), (10, "B"), (0, "C"), (-10, "D")],
"weekly_return_pct": [(5, "S"), (3, "A"), (1, "B"), (0, "C"), (-1, "D")],
"lstm_direction_accuracy":[(70, "S"), (60, "A"), (55, "B"), (50, "C"), (40, "D")],
"calmar_ratio": [(3.0, "S"), (2.0, "A"), (1.0, "B"), (0.5, "C"), (0.0, "D")],
}
thresholds = grade_rules.get(metric, [])
for threshold, grade in thresholds:
if value >= threshold:
return grade
return "F"
# ─────────────────────────────────────────
# 3. 전문가 패널 (Ollama LLM)
# ─────────────────────────────────────────
def generate_expert_panel(self, metrics):
"""5명의 전문가 역할로 Ollama에 평가를 요청.
Returns:
list[dict]: [{role, grade, comment, suggestion}, ...]
"""
from modules.services.ollama import OllamaManager
ollama = OllamaManager()
sig_corr = metrics.get("signal_correlation", {})
experts = [
{
"role": "Risk Manager",
"focus": "risk level assessment and bankruptcy risk",
"data": (
f"Sharpe={metrics.get('sharpe_ratio', 0):.2f}, "
f"Sortino={metrics.get('sortino_ratio', 0):.2f}, "
f"MDD={metrics.get('max_drawdown_pct', 0):.1f}%, "
f"Calmar={metrics.get('calmar_ratio', 0):.2f}"
)
},
{
"role": "Fund Manager",
"focus": "alpha generation vs market benchmark",
"data": (
f"TotalReturn={metrics.get('total_return_pct', 0):.2f}%, "
f"Alpha={metrics.get('alpha', 0):.2f}%, "
f"KOSPI={metrics.get('kospi_return_pct', 0):.2f}%, "
f"WeeklyReturn={metrics.get('weekly_return_pct', 0):.2f}%"
)
},
{
"role": "Quant Analyst",
"focus": "AI model validity and signal quality",
"data": (
f"LSTM_Accuracy={metrics.get('lstm_direction_accuracy', 0):.1f}%, "
f"TechCorr={sig_corr.get('tech', 0):.3f}, "
f"SentCorr={sig_corr.get('sentiment', 0):.3f}, "
f"LSTMCorr={sig_corr.get('lstm', 0):.3f}, "
f"BestSignal={metrics.get('best_signal_source', 'N/A')}"
)
},
{
"role": "Trader",
"focus": "trading strategy effectiveness",
"data": (
f"WinRate={metrics.get('win_rate_pct', 0):.1f}%, "
f"ProfitFactor={metrics.get('profit_factor', 0):.2f}, "
f"AvgHolding={metrics.get('avg_holding_days', 0):.1f}days, "
f"TotalTrades={metrics.get('total_trades', 0)}"
)
},
{
"role": "Portfolio PM",
"focus": "overall strategy direction and sustainability",
"data": (
f"WeeklyReturn={metrics.get('weekly_return_pct', 0):.2f}%, "
f"Sharpe={metrics.get('sharpe_ratio', 0):.2f}, "
f"WinRate={metrics.get('win_rate_pct', 0):.1f}%, "
f"Alpha={metrics.get('alpha', 0):.2f}%, "
f"MDD={metrics.get('max_drawdown_pct', 0):.1f}%"
)
}
]
results = []
for exp in experts:
prompt = (
f"You are a professional {exp['role']} evaluating an AI stock trading bot. "
f"Your focus: {exp['focus']}. "
f"Performance data: {exp['data']}. "
f"Respond ONLY with valid JSON (no markdown, no extra text): "
f"{{\"grade\":\"S|A|B|C|D|F\","
f"\"comment\":\"1 sentence evaluation in Korean\","
f"\"suggestion\":\"1 sentence improvement tip in Korean\"}}"
)
try:
resp = ollama.request_inference(prompt)
if not resp:
raise ValueError("Empty response from Ollama")
data = json.loads(resp)
results.append({
"role": exp["role"],
"grade": data.get("grade", "C"),
"comment": data.get("comment", "(응답 없음)"),
"suggestion": data.get("suggestion", "데이터 축적 필요")
})
except Exception as e:
print(f"[Evaluator] Expert panel [{exp['role']}] error: {e}")
results.append({
"role": exp["role"],
"grade": "C",
"comment": "평가 데이터가 부족합니다.",
"suggestion": "더 많은 거래 데이터 축적 후 재평가를 권장합니다."
})
return results
# ─────────────────────────────────────────
# 4. 주간 보고서 생성
# ─────────────────────────────────────────
def generate_weekly_report(self):
"""주간 성과 보고서 (텔레그램 HTML 형식) 반환."""
snapshots = self.perf_db.load_snapshots(days=7)
# 매매 완료 건은 30일치 사용 (주간 거래 수가 적을 수 있음)
trades = self.perf_db.load_trades(days=30)
metrics = self.compute_metrics(snapshots, trades)
if "error" in metrics:
return (
f"<b>[주간 성과 평가 보고서]</b>\n"
f"⚠️ {metrics['error']}\n"
f"<i>매일 오전 09:05~09:15에 스냅샷이 저장됩니다.</i>"
)
# 등급 계산
g_sharpe = self.get_grade("sharpe_ratio", metrics.get("sharpe_ratio", 0))
g_win = self.get_grade("win_rate_pct", metrics.get("win_rate_pct", 0))
g_mdd = self.get_grade("max_drawdown_pct", metrics.get("max_drawdown_pct", 0))
g_alpha = self.get_grade("alpha", metrics.get("alpha", 0))
g_weekly = self.get_grade("weekly_return_pct", metrics.get("weekly_return_pct", 0))
g_lstm = self.get_grade("lstm_direction_accuracy",
metrics.get("lstm_direction_accuracy", 0))
# 종합 등급 (Sharpe/Win/MDD/Alpha 평균)
grade_map = {"S": 5, "A": 4, "B": 3, "C": 2, "D": 1, "F": 0}
grade_rev = {v: k for k, v in grade_map.items()}
key_grades = [grade_map[g] for g in [g_sharpe, g_win, g_mdd, g_alpha]]
overall_grade = grade_rev[round(sum(key_grades) / len(key_grades))]
# 전문가 패널 (Ollama 호출)
try:
experts = self.generate_expert_panel(metrics)
except Exception as e:
print(f"[Evaluator] Expert panel skipped: {e}")
experts = []
now_str = datetime.now().strftime("%Y/%m/%d %H:%M")
corr = metrics.get("signal_correlation", {})
report = (
f"📊 <b>[주간 성과 평가 보고서]</b> <code>{now_str}</code>\n"
f"━━━━━━━━━━━━━━━━━━━━━━\n"
f"\n<b>■ 수익률</b>\n"
f" 주간: <code>{metrics.get('weekly_return_pct', 0):+.2f}%</code> [{g_weekly}]"
f" 누적: <code>{metrics.get('total_return_pct', 0):+.2f}%</code>\n"
f" Alpha: <code>{metrics.get('alpha', 0):+.2f}%</code> [{g_alpha}]"
f" vs KOSPI <code>{metrics.get('kospi_return_pct', 0):+.2f}%</code>\n"
f"\n<b>■ 리스크</b>\n"
f" Sharpe: <code>{metrics.get('sharpe_ratio', 0):.2f}</code> [{g_sharpe}]"
f" Sortino: <code>{metrics.get('sortino_ratio', 0):.2f}</code>\n"
f" MDD: <code>{metrics.get('max_drawdown_pct', 0):.1f}%</code> [{g_mdd}]"
f" Calmar: <code>{metrics.get('calmar_ratio', 0):.2f}</code>\n"
f"\n<b>■ 매매 통계</b>\n"
f" 승률: <code>{metrics.get('win_rate_pct', 0):.1f}%</code> [{g_win}]"
f" PF: <code>{metrics.get('profit_factor', 0):.2f}</code>\n"
f" 평균보유: <code>{metrics.get('avg_holding_days', 0):.1f}일</code>"
f" 완료매매: <code>{metrics.get('total_trades', 0)}건</code>\n"
f"\n<b>■ AI 품질</b>\n"
f" LSTM 방향정확도: <code>{metrics.get('lstm_direction_accuracy', 0):.1f}%</code>"
f" [{g_lstm}]\n"
f" 신호 상관도 — Tech: <code>{corr.get('tech', 0):.3f}</code>"
f" Sent: <code>{corr.get('sentiment', 0):.3f}</code>"
f" LSTM: <code>{corr.get('lstm', 0):.3f}</code>\n"
f" 최고기여 신호: <code>{metrics.get('best_signal_source', 'N/A')}</code>\n"
)
if experts:
role_icons = {
"Risk Manager": "🛡",
"Fund Manager": "💼",
"Quant Analyst": "🧮",
"Trader": "📈",
"Portfolio PM": "🏦"
}
report += "\n<b>■ 전문가 패널 의견</b>\n"
for exp in experts:
icon = role_icons.get(exp["role"], "👤")
report += (
f"{icon} <b>{exp['role']}</b> [{exp['grade']}]\n"
f" {exp['comment']}\n"
f" 💡 {exp['suggestion']}\n"
)
report += (
f"\n━━━━━━━━━━━━━━━━━━━━━━\n"
f"🏆 <b>종합 등급: [{overall_grade}]</b>\n"
f"<i>스냅샷 {metrics.get('snapshot_count', 0)}일 | 완료매매 {metrics.get('total_trades', 0)}건 기준</i>"
)
return report

View File

@@ -13,6 +13,7 @@ from modules.services.ollama import OllamaManager
from modules.services.telegram import TelegramMessenger from modules.services.telegram import TelegramMessenger
from modules.analysis.macro import MacroAnalyzer from modules.analysis.macro import MacroAnalyzer
from modules.utils.monitor import SystemMonitor 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.strategy.process import analyze_stock_process, calculate_position_size
try: try:
@@ -47,10 +48,6 @@ class AutoTradingBot:
self.kis = KISClient() self.kis = KISClient()
self.news = AsyncNewsCollector() self.news = AsyncNewsCollector()
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker) 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.messenger = TelegramMessenger()
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
@@ -95,6 +92,12 @@ class AutoTradingBot:
self.history_file = Config.HISTORY_FILE self.history_file = Config.HISTORY_FILE
self.load_trade_history() 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 하드웨어 점검 # 8. AI 하드웨어 점검
from modules.analysis.deep_learning import PricePredictor from modules.analysis.deep_learning import PricePredictor
PricePredictor.verify_hardware() PricePredictor.verify_hardware()
@@ -130,6 +133,49 @@ class AutoTradingBot:
except Exception: except Exception:
return {} 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): def _load_peak_prices(self):
"""트레일링 스탑용 최고가 데이터 로드""" """트레일링 스탑용 최고가 데이터 로드"""
peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json") peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json")
@@ -251,12 +297,20 @@ class AutoTradingBot:
except Exception as e: except Exception as e:
self.messenger.send_message(f"Watchlist update failed: {e}") self.messenger.send_message(f"Watchlist update failed: {e}")
elif command == 'evaluate':
self._pending_evaluate = True
async def run_cycle(self): async def run_cycle(self):
now = datetime.now() now = datetime.now()
# 0. 명령 큐 폴링 # 0. 명령 큐 폴링
self._process_commands() self._process_commands()
# 0-1. 즉시 평가 요청 처리 (IPC 'evaluate' 명령)
if self._pending_evaluate:
self._pending_evaluate = False
await self._run_weekly_evaluation()
# 1. 거시경제 분석 # 1. 거시경제 분석
macro_status = MacroAnalyzer.get_macro_status(self.kis) macro_status = MacroAnalyzer.get_macro_status(self.kis)
self.last_macro_status = macro_status self.last_macro_status = macro_status
@@ -316,6 +370,8 @@ class AutoTradingBot:
self.daily_trade_history = [] self.daily_trade_history = []
self.save_trade_history() self.save_trade_history()
self.report_sent = False self.report_sent = False
self.weekly_eval_sent = False
self._snapshot_taken_today = False
self.discovered_stocks.clear() self.discovered_stocks.clear()
self.watchlist_updated_today = False 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 not (9 <= now.hour < 15 or (now.hour == 15 and now.minute < 30)):
if now.hour == 15 and now.minute >= 40: if now.hour == 15 and now.minute >= 40:
self.send_daily_report() 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...") print("[Bot] Market Closed. Waiting...")
return 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')}") print(f"[Bot] Cycle Start: {now.strftime('%H:%M:%S')}")
# 7. 종목 분석 및 매매 # 7. 종목 분석 및 매매
@@ -365,14 +440,30 @@ class AutoTradingBot:
tracking_deposit = int(balance.get("deposit", 0)) 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: try:
for ticker, name in target_dict.items(): for ticker, name in target_dict.items():
prices = self.kis.get_daily_price(ticker) # OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback)
if not prices: 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 continue
investor_trend = self.kis.get_investor_trend(ticker)
# [v2.0] 보유 정보 전달 (분석 워커에서 동적 손절/익절 사용) # [v2.0] 보유 정보 전달 (분석 워커에서 동적 손절/익절 사용)
holding_info = None holding_info = None
if ticker in current_holdings: if ticker in current_holdings:
@@ -385,16 +476,22 @@ class AutoTradingBot:
'peak_price': self.peak_prices.get(ticker, 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( 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) investor_trend, macro_status, holding_info)
analysis_tasks.append(future) analysis_tasks.append(future)
# 결과 처리 # 결과 처리
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
for future in analysis_tasks: for future in analysis_tasks:
try: 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 = res['ticker']
ticker_name = target_dict.get(ticker, 'Unknown') ticker_name = target_dict.get(ticker, 'Unknown')
print(f"[Bot] [{ticker_name}] Score: {res['score']:.2f} ({res['decision']})" print(f"[Bot] [{ticker_name}] Score: {res['score']:.2f} ({res['decision']})"
@@ -458,6 +555,24 @@ class AutoTradingBot:
"reason": reason "reason": reason
}) })
self.save_trade_history() 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 tracking_deposit -= required_amount
# 최고가 초기 설정 # 최고가 초기 설정
@@ -484,14 +599,18 @@ class AutoTradingBot:
f" Reason: {reason}") f" Reason: {reason}")
self.messenger.send_message(msg) self.messenger.send_message(msg)
sell_price = float(h.get('current_price', 0))
self.daily_trade_history.append({ self.daily_trade_history.append({
"action": "SELL", "name": ticker_name, "action": "SELL", "name": ticker_name,
"qty": qty, "price": float(h.get('current_price', 0)), "qty": qty, "price": sell_price,
"yield": yld, "profit": profit_loss, "yield": yld, "profit": profit_loss,
"reason": reason "reason": reason
}) })
self.save_trade_history() self.save_trade_history()
# 성과 DB 매도 결과 기록
self.perf_db.close_trade(ticker, sell_price, yld)
# 최고가 기록 삭제 # 최고가 기록 삭제
if ticker in self.peak_prices: if ticker in self.peak_prices:
del self.peak_prices[ticker] del self.peak_prices[ticker]
@@ -510,11 +629,21 @@ class AutoTradingBot:
except Exception as e: except Exception as e:
print(f"[Bot] Cycle Loop Error: {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): 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( 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() self._load_peak_prices()

View File

@@ -7,9 +7,17 @@ import logging
import subprocess import subprocess
from telegram import Update from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes from telegram.ext import Application, CommandHandler, ContextTypes
# [디버깅] 파일 로깅 추가
log_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
"telegram_bot.log")
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
logging.basicConfig( logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO level=logging.INFO,
handlers=[logging.StreamHandler(), file_handler]
) )
logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING)
@@ -42,6 +50,7 @@ class TelegramBotServer:
return self.bot_instance is not None return self.bot_instance is not None
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
logging.info(f"[Command] /start from user {update.effective_user.id}")
await update.message.reply_text( await update.message.reply_text(
"<b>AI Trading Bot Command Center</b>\n" "<b>AI Trading Bot Command Center</b>\n"
"명령어 목록:\n" "명령어 목록:\n"
@@ -51,7 +60,8 @@ class TelegramBotServer:
"/update_watchlist - Watchlist 즉시 업데이트\n" "/update_watchlist - Watchlist 즉시 업데이트\n"
"/macro - 거시경제 지표 및 시장 위험도\n" "/macro - 거시경제 지표 및 시장 위험도\n"
"/system - PC 리소스(CPU/GPU) 상태\n" "/system - PC 리소스(CPU/GPU) 상태\n"
"/ai - AI 모델 학습 상태 조회\n\n" "/ai - AI 모델 학습 상태 조회\n"
"/evaluate - 즉시 성과 평가 보고서 생성\n\n"
"<b>[관리 명령어]</b>\n" "<b>[관리 명령어]</b>\n"
"/restart - 메인 봇 재시작 요청\n" "/restart - 메인 봇 재시작 요청\n"
"/exec <code>명령어</code> - 원격 명령어 실행\n" "/exec <code>명령어</code> - 원격 명령어 실행\n"
@@ -60,6 +70,7 @@ class TelegramBotServer:
) )
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
logging.info(f"[Command] /status from user {update.effective_user.id}")
if not self.refresh_bot_instance(): if not self.refresh_bot_instance():
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.") await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
return return
@@ -172,34 +183,67 @@ class TelegramBotServer:
await update.message.reply_text("데이터가 아직 수집되지 않았습니다.") await update.message.reply_text("데이터가 아직 수집되지 않았습니다.")
return return
status = "SAFE" msi = float(indices.get('MSI', 0))
msi = indices.get('MSI', 0)
if msi >= 50: if msi >= 50:
status = "DANGER" risk_status = "🔴 DANGER"
risk_desc = "시장 극도 불안정 - 매수 중단 권고"
elif msi >= 30: elif msi >= 30:
status = "CAUTION" risk_status = "🟡 CAUTION"
risk_desc = "시장 불안정 - 보수적 매매 권고"
else:
risk_status = "🟢 SAFE"
risk_desc = "시장 안정 - 정상 매매 가능"
msg = f"<b>Market Risk: {status}</b>\n\n" from datetime import datetime
now_str = datetime.now().strftime("%m/%d %H:%M")
if 'MSI' in indices: msg = f"<b>거시경제 지표</b> <code>{now_str}</code>\n"
msg += f"<b>Stress Index:</b> <code>{indices['MSI']}</code>\n" msg += f"━━━━━━━━━━━━━━━━━━\n"
msg += f"<b>Market Risk:</b> {risk_status}\n"
msg += f"<i>{risk_desc}</i>\n\n"
for k, v in indices.items(): # MSI 상세
if k != "MSI": msi_bar = "" * int(msi / 10) + "" * (10 - int(msi / 10))
msg += f"<b>Stress Index (MSI):</b> <code>{msi:.1f}/100</code>\n"
msg += f"<code>[{msi_bar}]</code>\n\n"
# 지수 상세
index_order = ["KOSPI", "KOSDAQ", "KOSPI200"]
for k in index_order:
if k not in indices:
continue
v = indices[k]
price = float(v.get('price', 0))
change = float(v.get('change', 0)) change = float(v.get('change', 0))
price = v.get('price', 0) change_val = float(v.get('change_val', 0))
high = float(v.get('high', 0))
low = float(v.get('low', 0))
prev_close = float(v.get('prev_close', 0))
volume = int(v.get('volume', 0))
if price == 0:
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
continue
if change > 0: if change > 0:
icon = "🔴" icon = "🔴"
chg_str = f"+{change}" chg_str = f"+{change:.2f}% (+{change_val:.2f}pt)"
elif change < 0: elif change < 0:
icon = "🔵" icon = "🔵"
chg_str = f"{change}" chg_str = f"{change:.2f}% ({change_val:.2f}pt)"
else: else:
icon = "" icon = ""
chg_str = f"{change}" chg_str = f"{change:.2f}%"
msg += f"{icon} <b>{k}</b>: {price} ({chg_str}%)\n" msg += f"{icon} <b>{k}:</b> <code>{price:,.2f}</code> {chg_str}\n"
if high and low:
msg += f" 고: <code>{high:,.2f}</code> 저: <code>{low:,.2f}</code>"
if prev_close:
msg += f" 전일종가: <code>{prev_close:,.2f}</code>"
msg += "\n"
if volume:
msg += f" 거래량: <code>{volume:,}천주</code>\n"
msg += "\n"
await update.message.reply_text(msg, parse_mode="HTML") await update.message.reply_text(msg, parse_mode="HTML")
@@ -256,10 +300,11 @@ class TelegramBotServer:
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.") await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
return return
from modules.config import Config
gpu = self.bot_instance.ollama_monitor.get_gpu_status() gpu = self.bot_instance.ollama_monitor.get_gpu_status()
msg = "<b>AI Model Status</b>\n" msg = "<b>AI Model Status</b>\n"
msg += "* <b>LLM Engine:</b> Ollama (Llama 3.1)\n" msg += f"* <b>LLM Engine:</b> Ollama ({Config.OLLAMA_MODEL})\n"
msg += f"* <b>Device:</b> {gpu.get('name', 'GPU')}\n" msg += f"* <b>Device:</b> {gpu.get('name', 'GPU')}\n"
if gpu: if gpu:
@@ -349,6 +394,29 @@ class TelegramBotServer:
except Exception as e: except Exception as e:
await update.message.reply_text(f"실행 오류: {e}") await update.message.reply_text(f"실행 오류: {e}")
async def evaluate_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/evaluate: 즉시 성과 평가 보고서 생성 (LLM 분석 포함)"""
await update.message.reply_text(
"📊 성과 평가를 실행합니다...\n"
"<i>LLM 전문가 패널 분석 포함 시 30초~1분 소요됩니다.</i>",
parse_mode="HTML"
)
try:
from modules.utils.performance_db import PerformanceDB
from modules.analysis.evaluator import PerformanceEvaluator
evaluator = PerformanceEvaluator()
loop = asyncio.get_running_loop()
report = await loop.run_in_executor(None, evaluator.generate_weekly_report)
if len(report) > 4000:
report = report[:4000] + "\n... (일부 생략)"
await update.message.reply_text(report, parse_mode="HTML")
except Exception as e:
logging.error(f"[Command] /evaluate error: {e}")
await update.message.reply_text(f"평가 오류: {e}")
def run(self): def run(self):
handlers = [ handlers = [
("start", self.start_command), ("start", self.start_command),
@@ -359,6 +427,7 @@ class TelegramBotServer:
("macro", self.macro_command), ("macro", self.macro_command),
("system", self.system_command), ("system", self.system_command),
("ai", self.ai_status_command), ("ai", self.ai_status_command),
("evaluate", self.evaluate_command),
("restart", self.restart_command), ("restart", self.restart_command),
("stop", self.stop_command), ("stop", self.stop_command),
("exec", self.exec_command) ("exec", self.exec_command)
@@ -377,6 +446,7 @@ class TelegramBotServer:
self.application.add_error_handler(error_handler) self.application.add_error_handler(error_handler)
logging.info("[Telegram] Command Server Started (Shared Memory IPC Mode).")
print("[Telegram] Command Server Started (Shared Memory IPC Mode).") print("[Telegram] Command Server Started (Shared Memory IPC Mode).")
try: try:

View File

@@ -0,0 +1,211 @@
"""
성과 데이터 영구 저장 - PerformanceDB
데이터 파일:
data/performance/daily_snapshots.json - 일별 자산 스냅샷
data/performance/trade_records.json - 강화 매매 기록 (영구 보관)
"""
import os
import json
from datetime import datetime, timedelta
from modules.config import Config
PERF_DIR = os.path.join(Config.DATA_DIR, "performance")
SNAPSHOTS_FILE = os.path.join(PERF_DIR, "daily_snapshots.json")
TRADES_FILE = os.path.join(PERF_DIR, "trade_records.json")
class PerformanceDB:
def __init__(self):
os.makedirs(PERF_DIR, exist_ok=True)
self._snapshots = self._load_json(SNAPSHOTS_FILE, [])
self._trades = self._load_json(TRADES_FILE, [])
def _load_json(self, path, default):
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"[PerformanceDB] Load failed {path}: {e}")
return default
return default
def _save_json(self, path, data):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[PerformanceDB] Save failed {path}: {e}")
# ─────────────────────────────────────────
# 일별 스냅샷
# ─────────────────────────────────────────
def save_daily_snapshot(self, total_eval, deposit, holdings_count, benchmark_close=None):
"""일별 자산 스냅샷 저장 (하루 1회 호출 권장).
Args:
total_eval (int): 총 평가액 (원)
deposit (int): 예수금 (원)
holdings_count (int): 보유 종목 수
benchmark_close (float|None): KOSPI 현재가 (벤치마크 비교용)
"""
today = datetime.now().strftime("%Y-%m-%d")
# 오늘 이미 저장된 스냅샷이 있으면 업데이트
for snap in self._snapshots:
if snap.get("date") == today:
snap["total_eval"] = total_eval
snap["deposit"] = deposit
snap["holdings_count"] = holdings_count
if benchmark_close is not None:
snap["benchmark_kospi_close"] = benchmark_close
self._save_json(SNAPSHOTS_FILE, self._snapshots)
return
# 일별/누적 수익률 계산
daily_return_pct = 0.0
cumulative_return_pct = 0.0
if self._snapshots:
prev_eval = self._snapshots[-1].get("total_eval", 0)
if prev_eval > 0:
daily_return_pct = (total_eval - prev_eval) / prev_eval * 100
initial_capital = self.get_initial_capital()
if initial_capital and initial_capital > 0:
cumulative_return_pct = (total_eval - initial_capital) / initial_capital * 100
snap = {
"date": today,
"total_eval": total_eval,
"deposit": deposit,
"holdings_count": holdings_count,
"benchmark_kospi_close": benchmark_close,
"daily_return_pct": round(daily_return_pct, 4),
"cumulative_return_pct": round(cumulative_return_pct, 4)
}
self._snapshots.append(snap)
self._save_json(SNAPSHOTS_FILE, self._snapshots)
print(f"[PerformanceDB] Snapshot saved: {today} "
f"total={total_eval:,}원 daily={daily_return_pct:+.2f}%")
# ─────────────────────────────────────────
# 매매 기록
# ─────────────────────────────────────────
def save_trade_record(self, action, ticker, name, qty, price,
scores_dict=None, reason="", macro_state="SAFE"):
"""매수/매도 기록 저장.
Args:
action (str): "BUY" | "SELL"
ticker (str): 종목 코드
name (str): 종목명
qty (int): 수량
price (float): 체결가
scores_dict (dict|None): 분석 점수 딕셔너리
{tech, sentiment, lstm_score, score, ai_confidence, prediction_change}
reason (str): 매매 사유
macro_state (str): 매크로 상태 ("SAFE"/"CAUTION"/"DANGER")
"""
sd = scores_dict or {}
now_iso = datetime.now().isoformat()
trade = {
"id": f"{ticker}_{now_iso}",
"action": action,
"ticker": ticker,
"name": name,
"qty": qty,
"price": price,
"timestamp": now_iso,
"reason": reason,
"macro_state": macro_state,
# 점수 (BUY 시에만 의미 있음)
"tech_score": float(sd.get("tech", 0.0)),
"sentiment_score": float(sd.get("sentiment", 0.0)),
"lstm_score": float(sd.get("lstm_score", 0.0)),
"total_score": float(sd.get("score", 0.0)),
"ai_confidence": float(sd.get("ai_confidence", 0.5)),
"ai_prediction_change": float(sd.get("prediction_change", 0.0)),
# 매도 후 채워지는 결과 필드
"outcome_return_pct": None,
"holding_days": None,
"closed_at": None
}
self._trades.append(trade)
self._save_json(TRADES_FILE, self._trades)
def close_trade(self, ticker, sell_price, sell_yield_pct=None):
"""가장 최근 미체결 BUY를 찾아 매도 결과를 기록.
Args:
ticker (str): 종목 코드
sell_price (float): 매도 체결가
sell_yield_pct (float|None): KIS에서 받은 수익률 (보조용)
"""
for trade in reversed(self._trades):
if (trade.get("ticker") == ticker
and trade.get("action") == "BUY"
and trade.get("outcome_return_pct") is None):
buy_price = trade.get("price", 0)
if buy_price and buy_price > 0:
outcome_return_pct = (sell_price - buy_price) / buy_price * 100
elif sell_yield_pct is not None:
outcome_return_pct = sell_yield_pct
else:
outcome_return_pct = 0.0
# 보유일 계산
holding_days = 0
buy_ts = trade.get("timestamp", "")
if buy_ts:
try:
buy_dt = datetime.fromisoformat(buy_ts)
holding_days = (datetime.now() - buy_dt).days
except Exception:
pass
trade["outcome_return_pct"] = round(outcome_return_pct, 4)
trade["holding_days"] = holding_days
trade["closed_at"] = datetime.now().isoformat()
self._save_json(TRADES_FILE, self._trades)
print(f"[PerformanceDB] Trade closed: {ticker} "
f"return={outcome_return_pct:.2f}% holding={holding_days}d")
return
print(f"[PerformanceDB] No open BUY found for {ticker}")
# ─────────────────────────────────────────
# 조회
# ─────────────────────────────────────────
def load_snapshots(self, days=90):
"""최근 N일 스냅샷 반환."""
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
return [s for s in self._snapshots if s.get("date", "") >= cutoff]
def load_trades(self, days=90):
"""최근 N일 매매 기록 반환."""
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
return [t for t in self._trades if t.get("timestamp", "")[:10] >= cutoff]
def get_initial_capital(self):
"""첫 스냅샷 기준 초기 자본 반환."""
if self._snapshots:
return self._snapshots[0].get("total_eval", 0)
return 0
def get_summary(self):
"""간단한 현황 딕셔너리 반환 (디버깅용)."""
return {
"total_snapshots": len(self._snapshots),
"total_trades": len(self._trades),
"closed_trades": sum(1 for t in self._trades
if t.get("outcome_return_pct") is not None),
"initial_capital": self.get_initial_capital()
}