765 lines
32 KiB
Python
765 lines
32 KiB
Python
"""
|
||
거래 성과 분석 서비스
|
||
|
||
[역할]
|
||
- 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], strategy_type: Optional[str] = None) -> str:
|
||
"""strategy_type 컬럼 우선, 없으면 entry_reason 문자열에서 전략 이름 추출."""
|
||
# strategy_type 컬럼이 있으면 우선 사용
|
||
if strategy_type and strategy_type.strip():
|
||
st = strategy_type.strip().lower()
|
||
_type_map = {
|
||
"swing": "mean_reversion",
|
||
"daytrading": "day_trade",
|
||
"kosdaq_scan": "kosdaq_scanner",
|
||
"short_term": "short_term",
|
||
}
|
||
return _type_map.get(st, st)
|
||
|
||
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, getattr(t, "strategy_type", None))
|
||
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 if p < 0][:5]
|
||
best_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[::-1] if p > 0][:5]
|
||
|
||
# ── 리스크 조정 수익률 지표 ────────────────────────────────────────────
|
||
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, getattr(trade, "strategy_type", None))
|
||
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, getattr(trade, "strategy_type", None))
|
||
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
|