Files
auto-stock/backend/app/services/trade_analyzer.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +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.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