""" 거래 성과 분석 서비스 [역할] - TradeHistory 레코드를 분석해 전략 성과 통계를 생성 - 전략별 / 요일별 승률, profit factor, 패턴 분석 - 리스크 조정 수익률 지표 (Sharpe, Sortino, Calmar) - 전략 드리프트 감지 (롤링 승률, 롤링 Sharpe, 연속 손실, MDD 가속) - 완료 거래 대상 매매일지 템플릿 자동 생성 - journals.py / analysis.py 엔드포인트에서 사용 """ import logging import math from collections import defaultdict 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" # ───────────────────────────────────────────────────────────────────────────── # 리스크 조정 수익률 지표 헬퍼 # ───────────────────────────────────────────────────────────────────────────── # 무위험이자율 3% (연), 일별 = 0.03/252 _RISK_FREE_ANNUAL = 0.03 _RISK_FREE_DAILY = _RISK_FREE_ANNUAL / 252.0 # 연환산 계수 _ANNUALIZE_FACTOR = math.sqrt(252) # 최소 거래일 수 (통계적 유의성) _MIN_TRADING_DAYS = 20 def _build_daily_pnl_series(trades: List["TradeHistory"]) -> Dict[date, float]: """SELL 거래의 created_at 날짜별 net_profit_loss를 집계하여 일별 PnL 딕셔너리 반환. Returns: {date(2026,3,1): 15000.0, date(2026,3,2): -3200.0, ...} """ daily_pnl: Dict[date, float] = defaultdict(float) for t in trades: if t.created_at is None: continue day = t.created_at.date() if isinstance(t.created_at, datetime) else t.created_at daily_pnl[day] += _to_float(t.net_profit_loss) return dict(daily_pnl) def _daily_pnl_to_returns(daily_pnl: Dict[date, float], initial_capital: float = 10_000_000.0) -> List[float]: """일별 PnL을 누적 equity curve로 변환 후 일별 수익률(%) 시계열 반환. Args: daily_pnl: 날짜별 PnL 금액 initial_capital: 기준 자본금 (기본 1000만원, equity curve 계산용) Returns: 일별 수익률 리스트 (소수 형태, 예: 0.01 = 1%) """ if not daily_pnl: return [] sorted_days = sorted(daily_pnl.keys()) equity = initial_capital daily_returns = [] for day in sorted_days: pnl = daily_pnl[day] if equity <= 0: # 파산 방지 — 잔여 자본이 0 이하이면 중단 break ret = pnl / equity daily_returns.append(ret) equity += pnl return daily_returns def _calc_sharpe_ratio(daily_returns: List[float]) -> Optional[float]: """Sharpe Ratio = (R - Rf) / sigma(R) * sqrt(252) 일별 수익률 시계열로부터 연환산 Sharpe Ratio를 계산. 거래일 _MIN_TRADING_DAYS 미만이면 None 반환. """ if len(daily_returns) < _MIN_TRADING_DAYS: return None n = len(daily_returns) mean_r = sum(daily_returns) / n excess = mean_r - _RISK_FREE_DAILY # 표본 표준편차 variance = sum((r - mean_r) ** 2 for r in daily_returns) / (n - 1) std = math.sqrt(variance) if variance > 0 else 0.0 if std == 0: return None return round((excess / std) * _ANNUALIZE_FACTOR, 4) def _calc_sortino_ratio(daily_returns: List[float]) -> Optional[float]: """Sortino Ratio = (R - Rf) / sigma_down(R) * sqrt(252) 하방 편차(downside deviation)만 사용 — 수익률이 무위험이자율 미만인 경우만 패널티. """ if len(daily_returns) < _MIN_TRADING_DAYS: return None n = len(daily_returns) mean_r = sum(daily_returns) / n excess = mean_r - _RISK_FREE_DAILY # 하방 편차: Rf 미만인 수익률에 대해서만 편차 계산 downside_diffs = [(r - _RISK_FREE_DAILY) ** 2 for r in daily_returns if r < _RISK_FREE_DAILY] if not downside_diffs: # 하방이 전혀 없으면 Sortino 무한대 — None 대신 높은 값 표시 return 99.0 if excess > 0 else None downside_variance = sum(downside_diffs) / n # 전체 N으로 나눔 (하방 편차 관행) downside_std = math.sqrt(downside_variance) if downside_std == 0: return None return round((excess / downside_std) * _ANNUALIZE_FACTOR, 4) def _calc_calmar_ratio(daily_returns: List[float]) -> Optional[float]: """Calmar Ratio = CAGR / |MDD| 일별 수익률로부터 CAGR과 MDD를 계산. """ if len(daily_returns) < _MIN_TRADING_DAYS: return None # Equity curve 구성 (1.0 기준) equity = 1.0 equities = [equity] for r in daily_returns: equity *= (1.0 + r) equities.append(equity) final_equity = equities[-1] n_days = len(daily_returns) # CAGR (연환산 복합 성장률) if final_equity <= 0 or n_days == 0: return None cagr = (final_equity ** (252.0 / n_days)) - 1.0 # MDD 계산 peak = equities[0] max_dd = 0.0 for eq in equities: if eq > peak: peak = eq dd = (eq - peak) / peak if peak > 0 else 0.0 if dd < max_dd: max_dd = dd if max_dd == 0: # drawdown이 없으면 — 전체 수익만 발생 return 99.0 if cagr > 0 else None return round(cagr / abs(max_dd), 4) def _calc_risk_adjusted_metrics(trades: List["TradeHistory"]) -> Dict[str, Any]: """Sharpe, Sortino, Calmar Ratio를 한 번에 계산하여 딕셔너리 반환. Returns: { "sharpe_ratio": float | None, "sortino_ratio": float | None, "calmar_ratio": float | None, "daily_returns_count": int } """ daily_pnl = _build_daily_pnl_series(trades) daily_returns = _daily_pnl_to_returns(daily_pnl) return { "sharpe_ratio": _calc_sharpe_ratio(daily_returns), "sortino_ratio": _calc_sortino_ratio(daily_returns), "calmar_ratio": _calc_calmar_ratio(daily_returns), "daily_returns_count": len(daily_returns), } # ───────────────────────────────────────────────────────────────────────────── # 전략 드리프트 감지 헬퍼 # ───────────────────────────────────────────────────────────────────────────── _DRIFT_ROLLING_WINDOW = 20 # 최근 20거래 / 20거래일 _DRIFT_WIN_RATE_THRESHOLD = 0.30 # 30% 미만 경고 _DRIFT_SHARPE_THRESHOLD = 0.0 # 0 이하 경고 _DRIFT_LOSS_STREAK_THRESHOLD = 5 # 5연패 이상 경고 _DRIFT_DRAWDOWN_THRESHOLD = -0.05 # -5% 이하 경고 def _detect_drift_alerts(trades: List["TradeHistory"]) -> List[Dict[str, Any]]: """전략 드리프트 감지 — 4가지 항목을 점검하여 경고 리스트 반환. 감지 항목: 1. rolling_win_rate: 최근 20거래 승률 < 30% 2. rolling_sharpe: 최근 20거래일 Sharpe <= 0 3. loss_streak: 최근 거래 중 5연패 이상 4. mdd_acceleration: 최근 일별 drawdown이 -5% 이하 Returns: [{"type": str, "value": float, "threshold": float, "message": str}, ...] """ alerts: List[Dict[str, Any]] = [] if not trades: return alerts pl_rates = [_to_float(t.profit_loss_rate) for t in trades] # ── 1. 롤링 승률 하락 ───────────────────────────────────────────────── recent_rates = pl_rates[-_DRIFT_ROLLING_WINDOW:] if len(recent_rates) >= _DRIFT_ROLLING_WINDOW: recent_wins = sum(1 for r in recent_rates if r > 0) rolling_wr = recent_wins / len(recent_rates) if rolling_wr < _DRIFT_WIN_RATE_THRESHOLD: alerts.append({ "type": "rolling_win_rate", "value": round(rolling_wr, 4), "threshold": _DRIFT_WIN_RATE_THRESHOLD, "message": f"최근 {_DRIFT_ROLLING_WINDOW}거래 승률 {rolling_wr*100:.1f}% (임계값 {_DRIFT_WIN_RATE_THRESHOLD*100:.0f}%)", }) # ── 2. 롤링 Sharpe 하락 ─────────────────────────────────────────────── daily_pnl = _build_daily_pnl_series(trades) daily_returns = _daily_pnl_to_returns(daily_pnl) recent_returns = daily_returns[-_DRIFT_ROLLING_WINDOW:] if len(recent_returns) >= _DRIFT_ROLLING_WINDOW: rolling_sharpe = _calc_sharpe_ratio(recent_returns) if rolling_sharpe is not None and rolling_sharpe <= _DRIFT_SHARPE_THRESHOLD: alerts.append({ "type": "rolling_sharpe", "value": rolling_sharpe, "threshold": _DRIFT_SHARPE_THRESHOLD, "message": f"최근 {_DRIFT_ROLLING_WINDOW}거래일 Sharpe {rolling_sharpe:.2f} (임계값 {_DRIFT_SHARPE_THRESHOLD:.1f})", }) # ── 3. 연속 손실 스트릭 ──────────────────────────────────────────────── # 최근 거래부터 역순으로 현재 연속 손실 카운트 current_streak = 0 for r in reversed(pl_rates): if r < 0: current_streak += 1 else: break if current_streak >= _DRIFT_LOSS_STREAK_THRESHOLD: alerts.append({ "type": "loss_streak", "value": current_streak, "threshold": _DRIFT_LOSS_STREAK_THRESHOLD, "message": f"현재 {current_streak}연패 진행 중 (임계값 {_DRIFT_LOSS_STREAK_THRESHOLD}연패)", }) # ── 4. MDD 가속 (최근 drawdown 체크) ────────────────────────────────── if len(daily_returns) >= 5: # 최근 equity curve에서 drawdown 계산 equity = 1.0 equities = [equity] for r in daily_returns: equity *= (1.0 + r) equities.append(equity) # 최근 5거래일의 drawdown 확인 recent_eq = equities[-6:] # 최근 5일분 + 직전 1일 if len(recent_eq) >= 2: recent_peak = max(recent_eq) recent_low = min(recent_eq) if recent_peak > 0: recent_dd = (recent_low - recent_peak) / recent_peak if recent_dd <= _DRIFT_DRAWDOWN_THRESHOLD: alerts.append({ "type": "mdd_acceleration", "value": round(recent_dd, 4), "threshold": _DRIFT_DRAWDOWN_THRESHOLD, "message": f"최근 drawdown {recent_dd*100:.2f}% (임계값 {_DRIFT_DRAWDOWN_THRESHOLD*100:.1f}%)", }) return alerts # ───────────────────────────────────────────────────────────────────────────── # 핵심 분석 함수 # ───────────────────────────────────────────────────────────────────────────── 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], "sharpe_ratio": float | None (연환산, Rf=3%), "sortino_ratio": float | None (하방편차만), "calmar_ratio": float | None (CAGR / |MDD|), "daily_returns_count": int (일별 수익률 데이터 수), "drift_alerts": [{type, value, threshold, message}, ...], "period_days": N, "generated_at": ISO 문자열 } """ since = datetime.utcnow() - timedelta(days=days) stmt = select(TradeHistory).where( and_( TradeHistory.order_type == "SELL", TradeHistory.status.in_(["COMPLETED", "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": [], "sharpe_ratio": None, "sortino_ratio": None, "calmar_ratio": None, "daily_returns_count": 0, "drift_alerts": [], "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]] # ── 리스크 조정 수익률 지표 ──────────────────────────────────────────── risk_metrics = _calc_risk_adjusted_metrics(trades) # ── 전략 드리프트 감지 ───────────────────────────────────────────────── drift_alerts = _detect_drift_alerts(trades) return { "summary": summary, "by_strategy": strategy_stats, "by_weekday": weekday_stats, "by_exit_type": exit_stats, "worst_stocks": worst_stocks, "best_stocks": best_stocks, "sharpe_ratio": risk_metrics["sharpe_ratio"], "sortino_ratio": risk_metrics["sortino_ratio"], "calmar_ratio": risk_metrics["calmar_ratio"], "daily_returns_count": risk_metrics["daily_returns_count"], "drift_alerts": drift_alerts, "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}% — 전체 청산의 절반 이상이 손절입니다. 진입 타이밍 재검토."}) # ── 리스크 조정 지표 인사이트 ────────────────────────────────────────── sharpe = perf.get("sharpe_ratio") if sharpe is not None: if sharpe < 0: insights.append({"level": "danger", "message": f"Sharpe Ratio {sharpe:.2f} — 무위험이자율(3%) 대비 음의 초과수익입니다. 전략 전면 재검토 필요."}) elif sharpe < 0.5: insights.append({"level": "warning", "message": f"Sharpe Ratio {sharpe:.2f} — 낮은 수준입니다. 리스크 대비 수익 개선이 필요합니다."}) elif sharpe >= 1.5: insights.append({"level": "info", "message": f"Sharpe Ratio {sharpe:.2f} — 우수한 리스크 조정 수익률입니다."}) calmar = perf.get("calmar_ratio") if calmar is not None and calmar < 1.0: insights.append({"level": "warning", "message": f"Calmar Ratio {calmar:.2f} — MDD 대비 수익률이 낮습니다. 낙폭 관리 강화 필요."}) # ── 드리프트 경고 인사이트 ───────────────────────────────────────────── drift_alerts = perf.get("drift_alerts", []) for alert in drift_alerts: insights.append({"level": "danger", "message": f"[드리프트 감지] {alert['message']}"}) 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.in_(["COMPLETED", "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