Files
auto-stock/backend/app/services/trade_analyzer.py
shuim 2baf06a5fd feat: 매매일지 자동 생성 + 전략 자동 최적화 시스템 추가
- trade_analyzer.py: 거래 성과 분석(승률/손익비/전략별 패턴), 템플릿 기반 일지 자동 생성
- strategy_optimizer.py: 그리드 서치 기반 파라미터 최적화, Sharpe 10% 개선 시 자동 적용
- analysis.py: /performance /insights /generate-journals /optimize-strategy /optimization-history 엔드포인트
- journals.py: POST /generate/{trade_id}, GET /stats 엔드포인트 추가
- models.py: StrategyOptimizationLog 테이블 추가
- main.py: post_market_analyzer_scheduler (16:00 KST 자동 실행)
- frontend: Analysis 페이지 추가 (성과 요약/인사이트/일지 생성/최적화 이력)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 09:28:10 +09:00

439 lines
18 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, 패턴 분석
- 완료 거래 대상 매매일지 템플릿 자동 생성
- 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