매매 성과 평가지표 시스템 구현
- 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:
421
modules/analysis/evaluator.py
Normal file
421
modules/analysis/evaluator.py
Normal 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
|
||||||
159
modules/bot.py
159
modules/bot.py
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
change = float(v.get('change', 0))
|
msg += f"<b>Stress Index (MSI):</b> <code>{msi:.1f}/100</code>\n"
|
||||||
price = v.get('price', 0)
|
msg += f"<code>[{msi_bar}]</code>\n\n"
|
||||||
|
|
||||||
if change > 0:
|
# 지수 상세
|
||||||
icon = "🔴"
|
index_order = ["KOSPI", "KOSDAQ", "KOSPI200"]
|
||||||
chg_str = f"+{change}"
|
for k in index_order:
|
||||||
elif change < 0:
|
if k not in indices:
|
||||||
icon = "🔵"
|
continue
|
||||||
chg_str = f"{change}"
|
v = indices[k]
|
||||||
else:
|
price = float(v.get('price', 0))
|
||||||
icon = "⚪"
|
change = float(v.get('change', 0))
|
||||||
chg_str = f"{change}"
|
change_val = float(v.get('change_val', 0))
|
||||||
|
high = float(v.get('high', 0))
|
||||||
msg += f"{icon} <b>{k}</b>: {price} ({chg_str}%)\n"
|
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:
|
||||||
|
icon = "🔴"
|
||||||
|
chg_str = f"+{change:.2f}% (+{change_val:.2f}pt)"
|
||||||
|
elif change < 0:
|
||||||
|
icon = "🔵"
|
||||||
|
chg_str = f"{change:.2f}% ({change_val:.2f}pt)"
|
||||||
|
else:
|
||||||
|
icon = "⚪"
|
||||||
|
chg_str = f"{change:.2f}%"
|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
211
modules/utils/performance_db.py
Normal file
211
modules/utils/performance_db.py
Normal 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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user