""" 거래 성과 분석 서비스 [역할] - TradeHistory 레코드를 분석해 전략 성과 통계를 생성 - 전략별 / 요일별 승률, profit factor, 패턴 분석 - 완료 거래 대상 매매일지 템플릿 자동 생성 - journals.py / analysis.py 엔드포인트에서 사용 """ import logging from datetime import datetime, timedelta, date from typing import Any, Dict, List, Optional from decimal import Decimal from sqlalchemy import select, and_, func from sqlalchemy.ext.asyncio import AsyncSession from app.models.models import TradeHistory, TradeJournal logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────────── # 내부 헬퍼 # ───────────────────────────────────────────────────────────────────────────── def _to_float(val) -> float: if val is None: return 0.0 try: return float(val) except (TypeError, ValueError): return 0.0 def _parse_strategy(entry_reason: Optional[str]) -> str: """entry_reason 문자열에서 전략 이름 추출.""" if not entry_reason: return "unknown" reason = entry_reason.lower() if "mean_reversion" in reason or "평균회귀" in reason: return "mean_reversion" if "short_term" in reason or "단기" in reason: return "short_term" if "kosdaq" in reason or "코스닥" in reason: return "kosdaq_scanner" if "day_trade" in reason or "데이트레이딩" in reason: return "day_trade" if "rule" in reason or "룰" in reason: return "rule_based" return "unknown" def _parse_exit_type(exit_reason: Optional[str]) -> str: """exit_reason 문자열에서 청산 유형 추출.""" if not exit_reason: return "unknown" reason = exit_reason.lower() if "stop_loss" in reason or "손절" in reason: return "stop_loss" if "trailing" in reason or "트레일링" in reason: return "trailing_stop" if "take_profit" in reason or "익절" in reason: return "take_profit" if "eod" in reason or "장마감" in reason or "강제" in reason: return "eod_forced" if "manual" in reason or "수동" in reason: return "manual" if "rsi" in reason: return "rsi_exit" return "unknown" # ───────────────────────────────────────────────────────────────────────────── # 핵심 분석 함수 # ───────────────────────────────────────────────────────────────────────────── async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any]: """ 최근 N일 완료 거래 성과 분석. Returns: { "summary": {전체 통계}, "by_strategy": {전략별 성과}, "by_weekday": {요일별 성과}, "by_exit_type": {청산 유형별 통계}, "worst_stocks": [최대 손실 종목 Top5], "best_stocks": [최대 수익 종목 Top5], "period_days": N, "generated_at": ISO 문자열 } """ since = datetime.utcnow() - timedelta(days=days) stmt = select(TradeHistory).where( and_( TradeHistory.order_type == "SELL", TradeHistory.status == "FILLED", TradeHistory.created_at >= since, ) ).order_by(TradeHistory.created_at) result = await db.execute(stmt) trades: List[TradeHistory] = result.scalars().all() if not trades: return { "summary": _empty_summary(), "by_strategy": {}, "by_weekday": {}, "by_exit_type": {}, "worst_stocks": [], "best_stocks": [], "period_days": days, "total_trades": 0, "generated_at": datetime.utcnow().isoformat(), } # ── 전체 통계 ────────────────────────────────────────────────────────── pl_rates = [_to_float(t.profit_loss_rate) for t in trades] pl_amounts = [_to_float(t.net_profit_loss) for t in trades] wins = [r for r in pl_rates if r > 0] losses = [r for r in pl_rates if r <= 0] win_rate = len(wins) / len(pl_rates) if pl_rates else 0.0 avg_win = sum(wins) / len(wins) if wins else 0.0 avg_loss = sum(losses) / len(losses) if losses else 0.0 profit_factor = ( (sum(wins) / abs(sum(losses))) if losses and sum(losses) != 0 else float("inf") ) gross_profit = sum(a for a in pl_amounts if a > 0) gross_loss = sum(a for a in pl_amounts if a < 0) net_pnl = gross_profit + gross_loss # 최대 연속 손실 max_consecutive_loss = _calc_max_consecutive_loss(pl_rates) summary = { "total_trades": len(trades), "win_trades": len(wins), "loss_trades": len(losses), "win_rate": round(win_rate * 100, 2), "avg_win_pct": round(avg_win, 4), "avg_loss_pct": round(avg_loss, 4), "profit_factor": round(min(profit_factor, 99.0), 4), "gross_profit": round(gross_profit, 2), "gross_loss": round(gross_loss, 2), "net_pnl": round(net_pnl, 2), "max_consecutive_loss": max_consecutive_loss, "risk_reward_ratio": round(abs(avg_win / avg_loss), 4) if avg_loss != 0 else 0.0, } # ── 전략별 성과 ────────────────────────────────────────────────────── by_strategy: Dict[str, list] = {} for t in trades: strat = _parse_strategy(t.entry_reason) by_strategy.setdefault(strat, []).append(_to_float(t.profit_loss_rate)) strategy_stats = {} for strat, rates in by_strategy.items(): w = [r for r in rates if r > 0] strategy_stats[strat] = { "total": len(rates), "win_rate": round(len(w) / len(rates) * 100, 2) if rates else 0.0, "avg_return": round(sum(rates) / len(rates), 4) if rates else 0.0, } # ── 요일별 성과 ────────────────────────────────────────────────────── weekday_names = ["월", "화", "수", "목", "금", "토", "일"] by_weekday: Dict[str, list] = {} for t in trades: wday = weekday_names[t.created_at.weekday()] if t.created_at else "알수없음" by_weekday.setdefault(wday, []).append(_to_float(t.profit_loss_rate)) weekday_stats = { d: { "total": len(rates), "win_rate": round(len([r for r in rates if r > 0]) / len(rates) * 100, 2), "avg_return": round(sum(rates) / len(rates), 4), } for d, rates in by_weekday.items() if rates } # ── 청산 유형별 ────────────────────────────────────────────────────── by_exit: Dict[str, list] = {} for t in trades: etype = _parse_exit_type(t.exit_reason) by_exit.setdefault(etype, []).append(_to_float(t.profit_loss_rate)) exit_stats = { etype: { "count": len(rates), "avg_return": round(sum(rates) / len(rates), 4), "win_rate": round(len([r for r in rates if r > 0]) / len(rates) * 100, 2), } for etype, rates in by_exit.items() if rates } # ── 종목별 집계 ────────────────────────────────────────────────────── stock_pnl: Dict[str, float] = {} for t in trades: code = t.stock_code stock_pnl[code] = stock_pnl.get(code, 0.0) + _to_float(t.net_profit_loss) sorted_stocks = sorted(stock_pnl.items(), key=lambda x: x[1]) worst_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[:5]] best_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[-5:][::-1]] return { "summary": summary, "by_strategy": strategy_stats, "by_weekday": weekday_stats, "by_exit_type": exit_stats, "worst_stocks": worst_stocks, "best_stocks": best_stocks, "period_days": days, "total_trades": len(trades), "generated_at": datetime.utcnow().isoformat(), } def _empty_summary() -> Dict[str, Any]: return { "total_trades": 0, "win_trades": 0, "loss_trades": 0, "win_rate": 0.0, "avg_win_pct": 0.0, "avg_loss_pct": 0.0, "profit_factor": 0.0, "gross_profit": 0.0, "gross_loss": 0.0, "net_pnl": 0.0, "max_consecutive_loss": 0, "risk_reward_ratio": 0.0, } def _calc_max_consecutive_loss(pl_rates: List[float]) -> int: max_streak = current_streak = 0 for r in pl_rates: if r <= 0: current_streak += 1 max_streak = max(max_streak, current_streak) else: current_streak = 0 return max_streak # ───────────────────────────────────────────────────────────────────────────── # 전략 인사이트 # ───────────────────────────────────────────────────────────────────────────── def build_insights(perf: Dict[str, Any]) -> List[Dict[str, str]]: """ 성과 데이터를 기반으로 개선 인사이트 목록을 생성. Returns: [{"level": "warning"|"info"|"danger", "message": "..."}] """ insights = [] summary = perf.get("summary", {}) win_rate = summary.get("win_rate", 0.0) pf = summary.get("profit_factor", 0.0) avg_loss = summary.get("avg_loss_pct", 0.0) max_cons_loss = summary.get("max_consecutive_loss", 0) rr = summary.get("risk_reward_ratio", 0.0) if win_rate < 40: insights.append({"level": "danger", "message": f"승률 {win_rate:.1f}% — RSI 과매도 기준을 강화하거나 진입 조건을 추가 필터링하세요."}) elif win_rate < 50: insights.append({"level": "warning", "message": f"승률 {win_rate:.1f}% — 50% 미만입니다. RSI_OVERSOLD_LEVEL 하향 조정을 검토하세요."}) else: insights.append({"level": "info", "message": f"승률 {win_rate:.1f}% — 양호합니다."}) if pf < 1.0: insights.append({"level": "danger", "message": f"Profit Factor {pf:.2f} — 손실이 수익보다 큽니다. 손절 기준 강화 필요."}) elif pf < 1.5: insights.append({"level": "warning", "message": f"Profit Factor {pf:.2f} — 개선 여지 있습니다."}) if rr < 1.0 and rr > 0: insights.append({"level": "warning", "message": f"손익비 {rr:.2f} — 1:1 미만입니다. ATR 익절 배수 또는 트레일링 스탑 완화를 검토하세요."}) if max_cons_loss >= 3: insights.append({"level": "warning", "message": f"최대 연속 손실 {max_cons_loss}회 — 연속 손절 발생 중. 진입 필터(추세 필터, 시장국면 필터) 점검 필요."}) if avg_loss < -8.0: insights.append({"level": "danger", "message": f"평균 손실 {avg_loss:.2f}% — 손절선({avg_loss:.2f}%)이 너무 넓습니다. STOP_LOSS_PERCENT 또는 ATR_STOP_MULTIPLIER 조정 권장."}) by_strategy = perf.get("by_strategy", {}) for strat, stats in by_strategy.items(): if stats.get("total", 0) >= 5 and stats.get("win_rate", 100) < 35: insights.append({"level": "warning", "message": f"'{strat}' 전략 승률 {stats['win_rate']:.1f}% — 해당 전략 파라미터 재검토 권장."}) by_exit = perf.get("by_exit_type", {}) stop_loss_info = by_exit.get("stop_loss", {}) if stop_loss_info.get("count", 0) > 0: sl_ratio = stop_loss_info["count"] / max(perf["total_trades"], 1) if sl_ratio > 0.5: insights.append({"level": "warning", "message": f"손절 청산 비율 {sl_ratio*100:.1f}% — 전체 청산의 절반 이상이 손절입니다. 진입 타이밍 재검토."}) return insights # ───────────────────────────────────────────────────────────────────────────── # 매매일지 생성 # ───────────────────────────────────────────────────────────────────────────── async def get_uncovered_trades(db: AsyncSession, days: int = 7) -> List[TradeHistory]: """일지가 아직 없는 완료 SELL 거래 목록을 반환.""" since = datetime.utcnow() - timedelta(days=days) # 이미 일지가 있는 trade_id 목록 covered_stmt = select(TradeJournal.trade_id).where(TradeJournal.trade_id.is_not(None)) covered_result = await db.execute(covered_stmt) covered_ids = {row[0] for row in covered_result.fetchall()} stmt = select(TradeHistory).where( and_( TradeHistory.order_type == "SELL", TradeHistory.status == "FILLED", TradeHistory.created_at >= since, TradeHistory.id.not_in(covered_ids) if covered_ids else True, ) ).order_by(TradeHistory.created_at.desc()) result = await db.execute(stmt) return result.scalars().all() def generate_journal_for_trade(trade: TradeHistory, perf_context: Optional[Dict] = None) -> str: """ 템플릿 기반 매매일지 내용 생성. Args: trade: 완료된 SELL TradeHistory 레코드 perf_context: 전체 성과 컨텍스트 (선택) Returns: 일지 본문 문자열 """ pl_rate = _to_float(trade.profit_loss_rate) pl_amount = _to_float(trade.net_profit_loss) exec_price = _to_float(trade.executed_price) exec_qty = trade.executed_quantity or trade.order_quantity or 0 strategy = _parse_strategy(trade.entry_reason) exit_type = _parse_exit_type(trade.exit_reason) # 결과 평가 if pl_rate > 3.0: result_label = "✅ 큰 수익 달성" result_comment = "목표 수익을 충분히 달성했습니다. 진입 타이밍과 전략이 잘 맞았습니다." elif pl_rate > 0: result_label = "✅ 소폭 수익" result_comment = "소폭 수익 실현. 목표 대비 아쉬운 결과이나 플러스 마감입니다." elif pl_rate > -3.0: result_label = "⚠️ 소폭 손실" result_comment = "소폭 손실. 리스크 관리 규칙 내에서 처리되었습니다." else: result_label = "❌ 손절 처리" result_comment = "손절 처리되었습니다. 진입 조건 및 시장 국면을 재검토하세요." # 청산 유형별 코멘트 exit_comments = { "stop_loss": "→ 손절 기준(-6% 또는 ATR 기반)에 도달하여 강제 청산.", "trailing_stop": "→ 트레일링 스탑(고점-1.5×ATR)에 도달. 고점 대비 일정 비율 하락.", "take_profit": "→ 익절 기준(+2R 또는 RSI 65+)에 도달하여 수익 확정.", "eod_forced": "→ 장 마감 강제 청산(15:20 EOD). 포지션 오버나이트 방지.", "rsi_exit": "→ RSI 과매수 구간 진입으로 부분/전량 청산.", "manual": "→ 수동 청산 처리.", "unknown": "→ 청산 사유 미상.", } exit_comment = exit_comments.get(exit_type, "→ 청산 완료.") lines = [ f"# 매매일지 — {trade.stock_code} ({trade.stock_name or ''})", f"**날짜**: {(trade.executed_at or trade.created_at).strftime('%Y-%m-%d %H:%M') if (trade.executed_at or trade.created_at) else '-'}", f"**전략**: {strategy}", "", f"## 거래 결과: {result_label}", f"- 체결가: {exec_price:,.0f}원 | 수량: {exec_qty:,}주", f"- 손익률: **{pl_rate:+.2f}%** | 손익금액: {pl_amount:+,.0f}원", f"- 청산 유형: `{exit_type}`", f" {exit_comment}", "", "## 진입 근거", f"{trade.entry_reason or '기록 없음'}", "", "## 청산 근거", f"{trade.exit_reason or '기록 없음'}", "", "## 평가 및 교훈", result_comment, ] # 성과 컨텍스트 추가 (있을 경우) if perf_context: overall_wr = perf_context.get("summary", {}).get("win_rate", 0) lines += [ "", f"---", f"*최근 {perf_context.get('period_days', 30)}일 전체 승률: {overall_wr:.1f}% (참고)*", ] return "\n".join(lines) async def create_journal_record( db: AsyncSession, trade: TradeHistory, content: str, ) -> TradeJournal: """TradeJournal 레코드를 DB에 저장 후 반환.""" pl_rate = _to_float(trade.profit_loss_rate) strategy = _parse_strategy(trade.entry_reason) exit_type = _parse_exit_type(trade.exit_reason) mood = "positive" if pl_rate > 0 else ("neutral" if pl_rate > -3.0 else "negative") tags = [strategy, exit_type] if abs(pl_rate) > 5.0: tags.append("significant") journal = TradeJournal( trade_id=trade.id, stock_code=trade.stock_code, stock_name=trade.stock_name, journal_date=trade.executed_at or trade.created_at or datetime.utcnow(), content=content, strategy_name=strategy, mood=mood, tags=tags, ) db.add(journal) await db.flush() return journal