Files
auto-stock/backend/app/services/trade_analyzer.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

1519 lines
67 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"
# [2026-10-09 R1] 기존 분류는 그대로 두고, 그동안 unknown 이던 사유만 새 키워드로 분리한다.
if "목표가" in reason or "target" in reason:
return "take_profit"
if "미청산" in reason or "stale" in reason:
return "stale_close"
if "bb상단" in reason or "bb_upper" in reason or "과매수" in reason:
return "bb_upper_exit"
return "unknown"
# ─────────────────────────────────────────────────────────────────────────────
# 비용 추정 (2026-10-09 R1)
# 운영 trade_history 의 commission IS NULL 인 SELL 은 net_profit_loss 가 비용 미차감(총손익)이다.
# 정의는 trade_helpers.calculate_trade_costs 와 동일(수수료×2 + 매도세). 무거운 import 를 피하려고
# settings 요율을 직접 참조한다(테스트로 동일성 고정).
# ─────────────────────────────────────────────────────────────────────────────
def _is_gross_basis_v2(t) -> bool:
"""commission 이 NULL 이면 비용이 net_profit_loss 에 반영되지 않은 행으로 본다."""
return getattr(t, "commission", None) is None
def _estimated_cost_v2(t) -> float:
"""commission 이 없는 SELL 의 왕복 비용 추정(원). commission 이 있으면 0(이미 차감됨)."""
if not _is_gross_basis_v2(t):
return 0.0
from app.core.config import settings
amount = _to_float(getattr(t, "total_amount", None))
if amount <= 0:
amount = _to_float(getattr(t, "executed_price", None)) * _trade_qty_v2(t)
if amount <= 0:
return 0.0
market = str(getattr(t, "market", None) or settings.DAY_TRADE_DEFAULT_TAX_MARKET)
tax = (settings.KR_SELL_TAX_RATE_KOSDAQ if market.upper() == "KOSDAQ"
else settings.KR_SELL_TAX_RATE_KOSPI)
return round(amount * (2 * settings.KR_COMMISSION_RATE + tax), 0)
def _adjusted_net_v2(t) -> float:
return _to_float(getattr(t, "net_profit_loss", None)) - _estimated_cost_v2(t)
def _cost_adjustment_v2(trades: List[Any]) -> Dict[str, Any]:
gross_rows = [t for t in trades if _is_gross_basis_v2(t)]
est = sum(_estimated_cost_v2(t) for t in gross_rows)
net = sum(_to_float(getattr(t, "net_profit_loss", None)) for t in trades)
return {
"gross_basis_trades": len(gross_rows),
"estimated_cost": round(est, 2),
"estimated_cost_adjusted_net": round(net - est, 2),
"cost_basis_note": "commission NULL 행은 비용 미차감(총손익)으로 보고 추정 비용을 차감한 값을 병기",
}
# ─────────────────────────────────────────────────────────────────────────────
# 리스크 조정 수익률 지표 헬퍼
# ─────────────────────────────────────────────────────────────────────────────
# 무위험이자율 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,
}
summary.update(_cost_adjustment_v2(trades))
# ── 전략별 성과 ──────────────────────────────────────────────────────
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(),
}
# ─────────────────────────────────────────────────────────────────────────────
# v2 성과 분석 (2026-10-09 하드닝 R1)
# - 라운드트립(포지션) 단위 귀인: entry_trade_id 가 NULL 이어도 종목별 FIFO 로 BUY 와 연결
# - 시스템 성과(manual/external 제외)와 전체를 분리
# - Sharpe/Sortino/Calmar: KRX 거래일 전체에 0 수익을 채우고 실제 자본(세션 starting_balance) 분모
# 기존 analyze_performance 및 헬퍼 시그니처는 그대로 둔다(호출처 호환).
# ─────────────────────────────────────────────────────────────────────────────
# 시스템 매매가 아닌(수동 판단·외부 유입) strategy_type. 전략 성과에서 분리한다.
NON_SYSTEM_STRATEGY_TYPES = frozenset({"manual", "external"})
# 라운드트립 매칭용 BUY 조회 룩백(일) — 윈도우 이전에 진입한 포지션의 청산을 연결하기 위함
_ROUNDTRIP_BUY_LOOKBACK_DAYS = 90
# 표본이 이보다 적으면 low_sample 로 표시(신뢰구간 병기)
_LOW_SAMPLE_ACTIVE_DAYS = 30
_FALLBACK_CAPITAL_KRW = 10_000_000.0
def _is_system_trade_v2(t) -> bool:
st = (getattr(t, "strategy_type", None) or "").strip().lower()
return st not in NON_SYSTEM_STRATEGY_TYPES
def _trade_qty_v2(t) -> int:
q = getattr(t, "executed_quantity", None) or getattr(t, "order_quantity", None) or 0
try:
return int(q)
except (TypeError, ValueError):
return 0
def _trade_sort_key_v2(t):
ts = getattr(t, "created_at", None) or datetime.min
return (ts, getattr(t, "id", 0) or 0)
def _match_roundtrips_v2(buys: List[Any], sells: List[Any]) -> List[Dict[str, Any]]:
"""BUY/SELL TradeHistory 를 종목별로 시간순 매칭해 SELL 마다 진입 BUY 를 연결한다.
- SELL.entry_trade_id 가 가리키는 BUY(남은 수량 있음)가 있으면 그것을 우선 사용
- 아니면 같은 종목의 가장 오래된 BUY lot(동일 strategy_type 우선) FIFO
- 수량이 여러 lot 에 걸치면 첫 소진 lot 에 귀속
- 매칭 불가(윈도우 밖 진입 등)는 entry_trade_id=None 의 단독 포지션
반환: SELL 순서와 무관한 dict 리스트(각 SELL 1개).
"""
out: List[Dict[str, Any]] = []
# 종목별 이벤트 시간순 처리
events: Dict[str, List[Any]] = defaultdict(list)
for b in buys:
events[b.stock_code].append(("B", b))
for sl in sells:
events[sl.stock_code].append(("S", sl))
for code, evs in events.items():
evs.sort(key=lambda e: (_trade_sort_key_v2(e[1]), 0 if e[0] == "B" else 1))
code_lots: List[List[Any]] = []
for kind, t in evs:
if kind == "B":
code_lots.append([t, _trade_qty_v2(t)])
continue
need = _trade_qty_v2(t)
chosen: Optional[List[Any]] = None
linked_by: Optional[str] = None
link_id = getattr(t, "entry_trade_id", None)
if link_id:
for lot in code_lots:
if lot[0].id == link_id and lot[1] > 0:
chosen = lot
linked_by = "entry_trade_id"
break
if chosen is None:
st = getattr(t, "strategy_type", None)
cands = [l for l in code_lots if l[1] > 0]
same = [l for l in cands if getattr(l[0], "strategy_type", None) == st]
pool = same or cands
chosen = pool[0] if pool else None
if chosen is not None:
linked_by = "fifo"
first_lot = chosen
remaining_need = need
if chosen is not None:
take = min(chosen[1], remaining_need)
chosen[1] -= take
remaining_need -= take
# 초과분은 다음 lot FIFO 로 소진(귀속은 첫 lot)
for lot in code_lots:
if remaining_need <= 0:
break
if lot[1] > 0:
tk = min(lot[1], remaining_need)
lot[1] -= tk
remaining_need -= tk
out.append({"sell": t, "buy": first_lot[0] if first_lot else None, "lot": first_lot,
"linked_by": linked_by})
# 포지션 종료 여부: lot 잔량 0 (lot 객체 공유)
for r in out:
r["lot_remaining"] = r["lot"][1] if r["lot"] is not None else 0
return out
def _sell_cost_basis_v2(sell, buy) -> Optional[float]:
"""해당 SELL 이 청산한 수량의 원가. BUY 체결가 우선, 없으면 매도대금-순손익 역산."""
qty = _trade_qty_v2(sell)
if buy is not None:
bp = _to_float(getattr(buy, "executed_price", None)) or _to_float(getattr(buy, "order_price", None))
if bp > 0 and qty > 0:
return bp * qty
sp = _to_float(getattr(sell, "executed_price", None))
if sp > 0 and qty > 0:
cost = sp * qty - _to_float(getattr(sell, "net_profit_loss", None))
if cost > 0:
return cost
return None
def _holding_bucket_v2(days: Optional[float]) -> str:
if days is None:
return "unknown"
if days < 1:
return "intraday"
if days <= 5:
return "1-5d"
if days <= 20:
return "6-20d"
return "20d+"
def _build_positions_v2(matches: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""SELL 매칭 결과를 진입 BUY 단위 포지션으로 합친다(분할매도 = 1 포지션)."""
groups: Dict[Any, List[Dict[str, Any]]] = {}
for m in matches:
key = m["buy"].id if m["buy"] is not None else ("orphan", m["sell"].id)
groups.setdefault(key, []).append(m)
positions: List[Dict[str, Any]] = []
for key, ms in groups.items():
buy = ms[0]["buy"]
sells = sorted((m["sell"] for m in ms), key=_trade_sort_key_v2)
net = sum(_to_float(s.net_profit_loss) for s in sells)
costs = [_sell_cost_basis_v2(m["sell"], buy) for m in ms]
cost = sum(c for c in costs if c) if any(costs) else None
entry_at = getattr(buy, "created_at", None) if buy is not None else None
last_exit = sells[-1].created_at
holding = None
if entry_at is not None and last_exit is not None:
holding = max((last_exit - entry_at).total_seconds() / 86400.0, 0.0)
ref = buy if buy is not None else sells[0]
closed = buy is None or all(m["lot_remaining"] <= 0 for m in ms)
positions.append({
"entry_trade_id": buy.id if buy is not None else None,
"stock_code": sells[0].stock_code,
"strategy_type": (getattr(ref, "strategy_type", None) or "").strip().lower() or "unknown",
"strategy": _parse_strategy(getattr(ref, "entry_reason", None), getattr(ref, "strategy_type", None)),
"entry_reason": (getattr(buy, "entry_reason", None) or "")[:80] if buy is not None else "",
"exit_types": sorted({
("manual_exit" if not _is_system_trade_v2(s) else _parse_exit_type(s.exit_reason))
for s in sells}),
"manual_exits": sum(1 for s in sells if not _is_system_trade_v2(s)),
"n_exits": len(sells),
"net_pnl": net,
"cost": cost,
"return_pct": (net / cost * 100.0) if cost else None,
"holding_days": holding,
"closed": closed,
"last_exit_at": last_exit,
})
positions.sort(key=lambda p: (p["last_exit_at"] is None, p["last_exit_at"]))
return positions
def _position_stats_v2(positions: List[Dict[str, Any]]) -> Dict[str, Any]:
"""포지션 단위 승률·PF·금액가중 수익률. 부분청산 진행 중(closed=False)은 제외하고 개수만 보고."""
closed = [p for p in positions if p["closed"]]
open_partial = len(positions) - len(closed)
wins = [p for p in closed if p["net_pnl"] > 0]
losses = [p for p in closed if p["net_pnl"] < 0]
gp = sum(p["net_pnl"] for p in wins)
gl = sum(p["net_pnl"] for p in losses)
if gl < 0:
pf = gp / abs(gl)
else:
pf = float("inf") if gp > 0 else 0.0
wr = [p["return_pct"] for p in wins if p["return_pct"] is not None]
lr = [p["return_pct"] for p in losses if p["return_pct"] is not None]
cost_total = sum(p["cost"] for p in closed if p["cost"])
net_total = sum(p["net_pnl"] for p in closed if p["cost"])
# [2026-10-09 R2] 금액 기준 평균손익·손익분기 승률. 레그(SELL 행) 단위 승률은 분할익절로 부풀어
# 주 지표로 쓰지 않는다. 손익분기 승률 = 평균손실/(평균이익+평균손실).
avg_win_amt = (gp / len(wins)) if wins else 0.0
avg_loss_amt = (abs(gl) / len(losses)) if losses else 0.0
be_wr = (avg_loss_amt / (avg_win_amt + avg_loss_amt) * 100.0
if (avg_win_amt + avg_loss_amt) > 0 else None)
# 수동 청산이 닫은(일부라도 수동 매도가 섞인) 봇 포지션은 별도 열로 귀속
man = [p for p in closed if p.get("manual_exits", 0) > 0]
sysx = [p for p in closed if p.get("manual_exits", 0) == 0]
return {
"avg_win_amount": round(avg_win_amt, 2),
"avg_loss_amount": round(-avg_loss_amt, 2),
"payoff_ratio_amount": round(avg_win_amt / avg_loss_amt, 4) if avg_loss_amt > 0 else None,
"breakeven_win_rate": round(be_wr, 2) if be_wr is not None else None,
"manual_exit_positions": len(man),
"manual_exit_net_pnl": round(sum(p["net_pnl"] for p in man), 2),
"system_exit_positions": len(sysx),
"system_exit_net_pnl": round(sum(p["net_pnl"] for p in sysx), 2),
"positions": len(closed),
"open_partial_positions": open_partial,
"win_positions": len(wins),
"loss_positions": len(losses),
"win_rate": round(len(wins) / len(closed) * 100, 2) if closed else 0.0,
"profit_factor": round(min(pf, 99.0), 4),
"net_pnl": round(sum(p["net_pnl"] for p in closed), 2),
"avg_win_pct": round(sum(wr) / len(wr), 4) if wr else 0.0,
"avg_loss_pct": round(sum(lr) / len(lr), 4) if lr else 0.0,
# 금액 가중: sum(순손익)/sum(원가). 단순 평균(%)은 소액 대박에 끌려가므로 병기.
"amount_weighted_return_pct": round(net_total / cost_total * 100.0, 4) if cost_total > 0 else None,
"avg_holding_days": (
round(sum(p["holding_days"] for p in closed if p["holding_days"] is not None)
/ max(1, sum(1 for p in closed if p["holding_days"] is not None)), 2)
if closed else None
),
}
def _group_positions_v2(positions: List[Dict[str, Any]], keyfn) -> Dict[str, Dict[str, Any]]:
groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for p in positions:
if not p["closed"]:
continue
groups[str(keyfn(p))].append(p)
out = {}
for k, ps in groups.items():
st = _position_stats_v2(ps)
out[k] = {
"positions": st["positions"],
"win_rate": st["win_rate"],
"net_pnl": st["net_pnl"],
"profit_factor": st["profit_factor"],
"amount_weighted_return_pct": st["amount_weighted_return_pct"],
}
return out
def _summary_from_trades_v2(trades: List[Any]) -> Dict[str, Any]:
"""analyze_performance 의 summary 와 같은 정의(SELL 행 단위, 금액 기준 PF)를 임의 SELL 목록에 적용."""
if not trades:
return _empty_summary()
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]
avg_win = sum(wins) / len(wins) if wins else 0.0
avg_loss = sum(losses) / len(losses) if losses else 0.0
gp = sum(a for a in pl_amounts if a > 0)
gl = sum(a for a in pl_amounts if a < 0)
if gl < 0:
pf = gp / abs(gl)
else:
pf = float("inf") if gp > 0 else 0.0
return {
"total_trades": len(trades),
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / len(pl_rates) * 100, 2),
"avg_win_pct": round(avg_win, 4),
"avg_loss_pct": round(avg_loss, 4),
"profit_factor": round(min(pf, 99.0), 4),
"gross_profit": round(gp, 2),
"gross_loss": round(gl, 2),
"net_pnl": round(gp + gl, 2),
"max_consecutive_loss": _calc_max_consecutive_loss(pl_rates),
"risk_reward_ratio": round(abs(avg_win / avg_loss), 4) if avg_loss != 0 else 0.0,
**_cost_adjustment_v2(trades),
}
def _krx_trading_days_v2(start: date, end: date) -> List[date]:
from app.core.market_schedule import is_krx_holiday
days: List[date] = []
d = start
while d <= end:
if d.weekday() < 5 and not is_krx_holiday(d):
days.append(d)
d += timedelta(days=1)
return days
def _daily_returns_v2(
daily_pnl: Dict[date, float],
trading_days: List[date],
capital_by_day: Optional[Dict[date, float]] = None,
fallback_capital: float = _FALLBACK_CAPITAL_KRW,
) -> Dict[str, Any]:
"""KRX 거래일 전체(무거래일=0)를 인덱스로 일별 수익률을 만든다.
분모: 그날 이전(포함) 가장 최근 세션 starting_balance(실제 총자산) — 없으면 fallback_capital
에 누적 실현손익을 더한 값(구 동작). 거래일이 아닌 날의 PnL 이 있으면 그날도 인덱스에 포함.
"""
days = sorted(set(trading_days) | set(daily_pnl.keys()))
cap_days = sorted((capital_by_day or {}).keys())
returns: List[float] = []
equity = fallback_capital
real_cap_days = 0
ci = 0
cur_cap: Optional[float] = None
for d in days:
while ci < len(cap_days) and cap_days[ci] <= d:
v = capital_by_day[cap_days[ci]]
if v and v > 0:
cur_cap = v
ci += 1
pnl = daily_pnl.get(d, 0.0)
base = cur_cap if cur_cap else equity
if cur_cap:
real_cap_days += 1
if base <= 0:
break
returns.append(pnl / base)
equity += pnl
n = len(days)
return {
"returns": returns,
"days": n,
"active_days": sum(1 for d in days if d in daily_pnl and daily_pnl[d] != 0.0),
"capital_source": (
"session_starting_balance" if real_cap_days == n and n > 0
else ("mixed" if real_cap_days > 0 else "fallback_fixed_capital")
),
}
def _sharpe_ci95_v2(sharpe: Optional[float], n: int) -> Optional[List[float]]:
"""Lo(2002) 근사 표준오차 기반 95% 신뢰구간(연환산 Sharpe). 표본 부족이면 None."""
if sharpe is None or n < 2:
return None
sr_d = sharpe / _ANNUALIZE_FACTOR
se = math.sqrt((1.0 + 0.5 * sr_d ** 2) / n) * _ANNUALIZE_FACTOR
return [round(sharpe - 1.96 * se, 4), round(sharpe + 1.96 * se, 4)]
def _risk_metrics_v2(
trades: List[Any],
trading_days: List[date],
capital_by_day: Optional[Dict[date, float]] = None,
) -> Dict[str, Any]:
daily_pnl = _build_daily_pnl_series(trades)
dr = _daily_returns_v2(daily_pnl, trading_days, capital_by_day)
r = dr["returns"]
sharpe = _calc_sharpe_ratio(r)
adj_daily: Dict[date, float] = defaultdict(float)
for t in trades:
ca = getattr(t, "created_at", None)
if ca is None:
continue
adj_daily[ca.date() if isinstance(ca, datetime) else ca] += _adjusted_net_v2(t)
adj_r = _daily_returns_v2(dict(adj_daily), trading_days, capital_by_day)["returns"]
return {
"sharpe_ratio_cost_adjusted": _calc_sharpe_ratio(adj_r),
"sharpe_ratio": sharpe,
"sharpe_ci95": _sharpe_ci95_v2(sharpe, len(r)),
"sortino_ratio": _calc_sortino_ratio(r),
"calmar_ratio": _calc_calmar_ratio(r),
"daily_returns_count": len(r),
"active_trading_days": dr["active_days"],
"capital_source": dr["capital_source"],
"low_sample": dr["active_days"] < _LOW_SAMPLE_ACTIVE_DAYS,
"_returns": r,
}
def _detect_drift_alerts_v2(trades: List[Any], daily_returns: List[float]) -> List[Dict[str, Any]]:
"""_detect_drift_alerts 의 승률/연패 경보는 유지하고, Sharpe/MDD 경보는 0 채움 시계열로 재계산."""
alerts = [a for a in _detect_drift_alerts(trades)
if a.get("type") not in ("rolling_sharpe", "mdd_acceleration")]
recent = daily_returns[-_DRIFT_ROLLING_WINDOW:]
if len(recent) >= _DRIFT_ROLLING_WINDOW:
rs = _calc_sharpe_ratio(recent)
if rs is not None and rs <= _DRIFT_SHARPE_THRESHOLD:
alerts.append({
"type": "rolling_sharpe", "value": rs, "threshold": _DRIFT_SHARPE_THRESHOLD,
"message": f"최근 {_DRIFT_ROLLING_WINDOW}거래일 Sharpe {rs:.2f} (임계값 {_DRIFT_SHARPE_THRESHOLD:.1f})",
})
if len(daily_returns) >= 5:
equity = 1.0
eqs = [equity]
for r in daily_returns:
equity *= (1.0 + r)
eqs.append(equity)
recent_eq = eqs[-6:]
peak, low = max(recent_eq), min(recent_eq)
if peak > 0:
dd = (low - peak) / peak
if dd <= _DRIFT_DRAWDOWN_THRESHOLD:
alerts.append({
"type": "mdd_acceleration", "value": round(dd, 4), "threshold": _DRIFT_DRAWDOWN_THRESHOLD,
"message": f"최근 drawdown {dd*100:.2f}% (임계값 {_DRIFT_DRAWDOWN_THRESHOLD*100:.1f}%)",
})
return alerts
def _hour_kst_v2(ts) -> Optional[str]:
"""DB 타임스탬프는 KST naive 로 통일돼 있다(timeutil). tz-aware 면 KST 로 변환."""
if ts is None or not isinstance(ts, datetime):
return None
if ts.tzinfo is not None:
from app.core.timeutil import KST
ts = ts.astimezone(KST)
return f"{ts.hour:02d}"
def _hour_stats_v2(groups: Dict[str, List[Any]]) -> Dict[str, Dict[str, Any]]:
out: Dict[str, Dict[str, Any]] = {}
for h in sorted(groups):
ts = groups[h]
nets = [_to_float(t.net_profit_loss) for t in ts]
adj = [_adjusted_net_v2(t) for t in ts]
out[h] = {
"count": len(ts),
"win_rate": round(sum(1 for a in adj if a > 0) / len(ts) * 100, 2),
"net_pnl": round(sum(nets), 2),
"estimated_cost_adjusted_net": round(sum(adj), 2),
}
return out
def _compose_performance_v2(
sells: List[Any],
buys: List[Any],
trading_days: List[date],
capital_by_day: Optional[Dict[date, float]],
days: int,
) -> Dict[str, Any]:
"""DB 비의존 순수 조립부(테스트 대상)."""
system_sells = [t for t in sells if _is_system_trade_v2(t)]
external_sells = [t for t in sells if not _is_system_trade_v2(t)]
# [2026-10-09 R1] 수동 매도가 시스템 BUY lot 을 소진하면 그 포지션은 시스템 포지션에 'manual_exit'
# 로 귀속한다(이전에는 시스템 SELL 만 매칭해 lot 이 영구 open 으로 남았다). 전체 SELL 을 한 번에
# 매칭해야 lot 소진 순서가 실제와 같다.
all_matches = _match_roundtrips_v2(buys, sells)
def _buy_is_system(m) -> bool:
return m["buy"] is not None and _is_system_trade_v2(m["buy"])
sys_matches = [m for m in all_matches
if _buy_is_system(m) or (m["buy"] is None and _is_system_trade_v2(m["sell"]))]
ext_matches = [m for m in all_matches if m not in sys_matches]
matches = sys_matches
positions = _build_positions_v2(sys_matches)
ext_positions = _build_positions_v2(ext_matches)
manual_exit_sells = [m["sell"] for m in sys_matches if not _is_system_trade_v2(m["sell"])]
sys_summary = _summary_from_trades_v2(system_sells)
risk = _risk_metrics_v2(system_sells, trading_days, capital_by_day)
daily_returns = risk.pop("_returns")
strat_stats: Dict[str, Any] = {}
by_strat_rows: Dict[str, List[float]] = defaultdict(list)
for t in system_sells:
by_strat_rows[_parse_strategy(t.entry_reason, getattr(t, "strategy_type", None))].append(
_to_float(t.profit_loss_rate))
for k, rates in by_strat_rows.items():
strat_stats[k] = {
"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),
}
exit_rows: Dict[str, List[float]] = defaultdict(list)
for t in system_sells:
exit_rows[_parse_exit_type(t.exit_reason)].append(_to_float(t.profit_loss_rate))
exit_stats = {
k: {"count": len(v), "avg_return": round(sum(v) / len(v), 4),
"win_rate": round(len([r for r in v if r > 0]) / len(v) * 100, 2)}
for k, v in exit_rows.items() if v
}
sys_sell_matches = [m for m in sys_matches if _is_system_trade_v2(m["sell"])]
linked = sum(1 for m in sys_sell_matches if m["buy"] is not None)
explicit = sum(1 for m in sys_sell_matches if m.get("linked_by") == "entry_trade_id")
fifo = sum(1 for m in sys_sell_matches if m.get("linked_by") == "fifo")
n_sys = len(system_sells)
# 시간대(KST naive) 귀인: 청산 시각 / 진입(매칭된 BUY) 시각, 비용 추정 차감 net 병기
by_exit_hour: Dict[str, List[Any]] = defaultdict(list)
by_entry_hour: Dict[str, List[Any]] = defaultdict(list)
for m in sys_sell_matches:
sl = m["sell"]
eh = _hour_kst_v2(getattr(sl, "created_at", None))
if eh is not None:
by_exit_hour[eh].append(sl)
if m["buy"] is not None:
nh = _hour_kst_v2(getattr(m["buy"], "created_at", None))
if nh is not None:
by_entry_hour[nh].append(sl)
return {
"summary": sys_summary, # 시스템(manual/external 제외) SELL(레그) 단위 — 주 지표 아님
"summary_basis": "sell_leg", # 분할익절 레그 단위. 주 지표는 position_summary
"summary_all": _summary_from_trades_v2(sells), # 참고: 수동·외부 포함
"external_summary": _summary_from_trades_v2(external_sells),
"position_summary": _position_stats_v2(positions),
"external_position_summary": _position_stats_v2(ext_positions),
"by_strategy": strat_stats,
"by_strategy_position": _group_positions_v2(positions, lambda p: p["strategy"]),
"by_entry_reason": _group_positions_v2(positions, lambda p: p["entry_reason"] or "unknown"),
"by_holding_period": _group_positions_v2(positions, lambda p: _holding_bucket_v2(p["holding_days"])),
"by_exit_type": exit_stats,
"roundtrip": {
"sells": n_sys,
"matched_to_buy": linked,
"unmatched_sells": n_sys - linked,
"linked_by_entry_trade_id": explicit,
"linked_by_fifo_estimate": fifo,
# FIFO 추정 비중이 높으면 보유기간·진입사유 귀인은 추정치다(운영 SELL 의 entry_trade_id 는 전부 NULL).
"attribution_confidence": (
"high" if n_sys and explicit / n_sys >= 0.8
else ("low_fifo_estimate" if n_sys else "n/a")),
"method": "entry_trade_id 우선, 없으면 종목별 FIFO(동일 strategy_type 우선)",
},
"manual_exit_summary": {
**_summary_from_trades_v2(manual_exit_sells),
"note": "시스템 BUY lot 을 청산한 수동 매도(시스템 SELL summary 에는 미포함, 포지션에는 manual_exit 로 귀속)",
},
"by_exit_hour": _hour_stats_v2(by_exit_hour),
"by_entry_hour": _hour_stats_v2(by_entry_hour),
**risk,
"drift_alerts": _detect_drift_alerts_v2(system_sells, daily_returns),
"period_days": days,
"total_trades": len(system_sells),
}
async def analyze_performance_v2(db: AsyncSession, days: int = 30) -> Dict[str, Any]:
"""analyze_performance 의 보정판. summary 는 manual/external 을 제외한 시스템 성과이며,
포지션(라운드트립) 단위 통계·보유기간/진입사유별 귀인·0 채움 Sharpe 를 추가로 반환한다.
build_insights() 와 호환(summary/by_strategy/by_exit_type/drift_alerts/total_trades 키 유지)."""
from app.models.models import TradingSession
now = now_kst()
since = now - timedelta(days=days)
sells_res = await db.execute(
select(TradeHistory).where(and_(
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["COMPLETED", "FILLED"]),
TradeHistory.created_at >= since,
)).order_by(TradeHistory.created_at)
)
sells: List[TradeHistory] = sells_res.scalars().all()
buys: List[TradeHistory] = []
if sells:
buys_res = await db.execute(
select(TradeHistory).where(and_(
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["COMPLETED", "FILLED", "PARTIAL"]),
TradeHistory.created_at >= since - timedelta(days=_ROUNDTRIP_BUY_LOOKBACK_DAYS),
)).order_by(TradeHistory.created_at)
)
buys = buys_res.scalars().all()
capital_by_day: Dict[date, float] = {}
sess_res = await db.execute(
select(TradingSession.session_date, TradingSession.starting_balance)
.where(TradingSession.session_date >= since.date())
)
for d, bal in sess_res.all():
if d is not None and bal is not None and float(bal) > 0:
capital_by_day[d] = float(bal)
trading_days = _krx_trading_days_v2(since.date(), now.date())
perf = _compose_performance_v2(sells, buys, trading_days, capital_by_day, days)
perf["generated_at"] = now.isoformat()
return perf
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,
"gross_basis_trades": 0,
"estimated_cost": 0.0,
"estimated_cost_adjusted_net": 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)
# [2026-10-09 R2] 포지션(BUY→전량청산) 단위 집계가 있으면 그것을 주 지표로 쓴다. SELL 레그 단위
# 승률/손익비는 분할익절 레그가 '승'을 부풀려 손익분기 승률을 과대 평가하게 만든다.
pos = perf.get("position_summary") or {}
pos_based = bool(pos.get("positions"))
if pos_based:
win_rate = pos.get("win_rate", 0.0)
pf = pos.get("profit_factor", 0.0)
avg_loss = pos.get("avg_loss_pct", 0.0)
_aw = pos.get("avg_win_pct", 0.0)
rr = abs(_aw / avg_loss) if avg_loss else 0.0
_be = pos.get("breakeven_win_rate")
insights.append({"level": "info", "message": (
f"[포지션 단위 {pos['positions']}건] 승률 {win_rate:.1f}%"
+ (f" / 손익분기 승률 {_be:.1f}%" if _be is not None else "")
+ f" / PF {pf:.2f} (레그 단위 승률 {summary.get('win_rate', 0.0):.1f}% 는 참고용)")})
if pos.get("manual_exit_positions"):
insights.append({"level": "info", "message": (
f"수동 청산이 닫은 봇 포지션 {pos['manual_exit_positions']}건 순손익 "
f"{pos.get('manual_exit_net_pnl', 0):,.0f}원 / 시스템 청산 {pos.get('system_exit_positions', 0)}건 "
f"{pos.get('system_exit_net_pnl', 0):,.0f}원 — 봇 성과 판단 시 분리해서 볼 것")})
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