1. trade_executor: 손절값 -6.0 하드코딩 → -settings.STOP_LOSS_PERCENT (7%) 2. mdd_manager: MDD 강제매도 시 loss_tracker 호출 추가 3. risk_validator, day_trading: 가격필터 < → <= (5,000원 정확히 차단) 4. daytrading/entry_service: 글로벌 자본 사용률(95%) 가드 추가 5. trade_analyzer: 수익률 0%를 손실에서 제외 (< 0으로 변경) 6. db_worker: 종료 시 큐 drain + DLQ 백업으로 데이터 유실 방지 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
439 lines
18 KiB
Python
439 lines
18 KiB
Python
"""
|
||
거래 성과 분석 서비스
|
||
|
||
[역할]
|
||
- 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.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": [],
|
||
"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.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
|