Files
auto-stock/backend/app/services/trade_analyzer.py
shuim 5c4803b339 fix(journal): 시간손절을 손절과 분리(time_stop) + 청산 설명 임계값을 실효 설정값으로
- _parse_exit_type: '시간 손절'/'시간손절'/time_stop → time_stop (이전엔 '손절' 글자로 stop_loss 로 분류돼
  -0.1% 시간손절이 '-6% 손절 도달'로 표시되고 손절 비율 인사이트가 부풀었다)
- 일지 청산 설명의 하드코딩(-6%, RSI 65+, 1.5×ATR)을 settings 실효값으로(§4)
- 분석 화면 청산 유형 한글 표시명

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:06:08 +09:00

869 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
거래 성과 분석 서비스
[역할]
- 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, StockAnalysis, Portfolio
from app.core.timeutil import now_kst
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()
# 시간손절(DT "시간 손절: N분 경과…", 스윙 "시간손절 …")은 손실 한도 도달이 아니라
# 보유 시간 초과 후 조기 정리다 — "손절" 글자만 보고 stop_loss 로 묶지 않는다(2026-10-07).
if "time_stop" in reason or "시간 손절" in reason or "시간손절" in reason:
return "time_stop"
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 _resolve_stock_names(db: AsyncSession, codes: List[str]) -> Dict[str, str]:
"""trade_history.stock_name 이 비어 있는 종목의 종목명을 보강 조회.
stock_analysis → portfolio 순으로 조회하며, 없으면 키를 만들지 않는다(호출부에서 코드로 대체).
"""
names: Dict[str, str] = {}
if not codes:
return names
try:
rows = await db.execute(
select(StockAnalysis.stock_code, StockAnalysis.stock_name)
.where(StockAnalysis.stock_code.in_(codes))
)
for code, name in rows.all():
if name and code not in names:
names[code] = name
remaining = [c for c in codes if c not in names]
if remaining:
rows = await db.execute(
select(Portfolio.stock_code, Portfolio.stock_name)
.where(Portfolio.stock_code.in_(remaining))
)
for code, name in rows.all():
if name and code not in names:
names[code] = name
except Exception as e: # 종목명은 부가 정보이므로 실패해도 분석은 계속
logger.warning(f"종목명 보강 조회 실패: {e}")
return names
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 {stock_code, stock_name, net_pnl, trade_count}],
"best_stocks": [최대 수익 종목 Top5 {stock_code, stock_name, net_pnl, trade_count}],
"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 = now_kst() - 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": now_kst().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
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
# [2026-10-01] PF 는 **금액** 기준이다(gross_profit / |gross_loss|). 이전에는 손익률(%)
# 합계로 계산해 gross_profit 266,370 / gross_loss -430,619 인데 PF 1.2165 가 나왔다
# (소액 거래의 큰 %가 대금 큰 거래의 작은 %를 압도). 손실 없음 → inf(아래서 99 로 캡),
# 수익도 손실도 없음 → 0.
if gross_loss < 0:
profit_factor = gross_profit / abs(gross_loss)
elif gross_profit > 0:
profit_factor = float("inf")
else:
profit_factor = 0.0
# 최대 연속 손실
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] = {}
stock_names: Dict[str, str] = {}
stock_trade_count: Dict[str, int] = {}
for t in trades:
code = t.stock_code
stock_pnl[code] = stock_pnl.get(code, 0.0) + _to_float(t.net_profit_loss)
stock_trade_count[code] = stock_trade_count.get(code, 0) + 1
# 종목명은 최신 거래 기준(trades는 created_at 오름차순)으로 갱신
if t.stock_name:
stock_names[code] = t.stock_name
# trade_history.stock_name 이 비어 있는 종목은 stock_analysis 에서 보강
missing = [c for c in stock_pnl if c not in stock_names]
if missing:
stock_names.update(await _resolve_stock_names(db, missing))
def _stock_row(code: str, pnl: float) -> Dict[str, Any]:
return {
"stock_code": code,
"stock_name": stock_names.get(code) or code,
"net_pnl": round(pnl, 2),
"trade_count": stock_trade_count.get(code, 0),
}
sorted_stocks = sorted(stock_pnl.items(), key=lambda x: x[1])
# 손실 종목만 (음수), 수익 종목만 (양수) 필터링하여 색상 혼동 방지
worst_stocks = [_stock_row(c, p) for c, p in sorted_stocks if p < 0][:5]
best_stocks = [_stock_row(c, p) 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": now_kst().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}% — 양호합니다."})
# 거래 없음/수익·손실 모두 0 이면 PF=0 은 "손실>수익"이 아니라 판단 불가 — 인사이트 생략
_has_pnl = bool(summary.get("gross_profit") or summary.get("gross_loss"))
if not _has_pnl:
pass
elif 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
# ─────────────────────────────────────────────────────────────────────────────
# 매매일지 생성
# ─────────────────────────────────────────────────────────────────────────────
# 일지 대상 SELL 상태. 성과 집계(analyze_performance)의 COMPLETED/FILLED 와 **의도적으로 다르다**:
# PARTIAL 은 실제 체결된 매도(부분 체결)라 기록할 가치가 있고, 이후 COMPLETED 로 갱신돼도
# 같은 TradeHistory.id 라 trade_id 커버 판정으로 중복되지 않는다. PENDING(체결 미확인)·
# FAILED(거부)는 손익이 확정되지 않았거나 거래가 없으므로 제외한다.
# 성과 집계에는 PARTIAL 을 넣지 않는다(손익 이중 계상 방지) — 이 상수는 일지 전용.
JOURNAL_SELL_STATUSES = ("COMPLETED", "FILLED", "PARTIAL")
async def get_uncovered_trades(db: AsyncSession, days: int = 7) -> List[TradeHistory]:
"""일지가 아직 없는 체결 SELL(COMPLETED/FILLED/PARTIAL) 거래 목록을 반환."""
return await get_uncovered_trades_v2(db, days=days, min_age_minutes=0)
async def get_uncovered_trades_v2(
db: AsyncSession, days: int = 7, min_age_minutes: int = 0
) -> List[TradeHistory]:
"""get_uncovered_trades + 유예시간: created_at 이 min_age_minutes 이내인 SELL 은 제외.
매도 직후 `_create_journal_background`(MCP 분석 최대 30초 후 큐 등록)가 같은 매도의
일지를 만들 시간을 준다 — 장중 주기 태스크가 그 사이에 중복 생성하지 않도록.
"""
since = now_kst() - timedelta(days=days)
cutoff = now_kst() - timedelta(minutes=max(0, int(min_age_minutes)))
# 이미 일지가 있는 trade_id 목록
# [2026-09-29 B3] `> 0` 조건이 필수다. 과거 호출부가 trade_id 미확보 시 `0` 을
# 넣어서, 0 이 covered 집합에 섞이면 존재하지 않는 PK 를 커버로 세게 된다
# (역으로 실제 거래는 계속 uncovered → 중복 일지). NULL/0 은 둘 다 "커버 안 함".
covered_stmt = select(TradeJournal.trade_id).where(TradeJournal.trade_id > 0)
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_(JOURNAL_SELL_STATUSES),
TradeHistory.created_at >= since,
TradeHistory.created_at <= cutoff,
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 = "손절 처리되었습니다. 진입 조건 및 시장 국면을 재검토하세요."
# 청산 유형별 코멘트
# 임계값은 하드코딩하지 않고 실효 설정값을 쓴다(CLAUDE.md §4).
from app.core.config import settings as _s
exit_comments = {
"stop_loss": (
f"→ 손절 기준(스윙 -{abs(float(_s.STOP_LOSS_PERCENT)):g}% / 고변동 -{abs(float(_s.STOP_LOSS_HIGH_VOL)):g}%, "
f"ATR×{float(_s.ATR_STOP_MULTIPLIER):g}, 데이트레이딩은 진입 시 손절가)에 도달하여 청산."
),
"time_stop": (
f"→ 시간손절: 데이트레이딩은 진입 {int(_s.DAY_TRADE_TIME_STOP_MINUTES)}분 경과 후 수익률 "
f"{float(_s.DAY_TRADE_TIME_STOP_MIN_GAIN) * 100:+.1f}% 미만, 스윙은 장기 보유 수익 미달로 조기 정리. "
"손실 한도 도달이 아니다."
),
"trailing_stop": f"→ 트레일링 스탑(고점-{float(_s.ATR_TRAILING_MULTIPLIER):g}×ATR)에 도달. 고점 대비 일정 비율 하락.",
"take_profit": (
f"→ 익절 기준(RSI {float(_s.MEAN_REVERSION_RSI_EXIT_THRESHOLD):g}+ 1차 / "
f"{float(_s.MEAN_REVERSION_RSI_FULL_EXIT):g}+ 전량, 데이트레이딩 목표가)에 도달하여 수익 확정."
),
"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 now_kst(),
content=content,
strategy_name=strategy,
mood=mood,
tags=tags,
)
db.add(journal)
await db.flush()
return journal