From 418371e1c89d0e6a3686be115c5092a7257a056d Mon Sep 17 00:00:00 2001 From: shuim Date: Mon, 28 Sep 2026 11:51:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=EC=86=90=EC=9D=B5=20Top5=20?= =?UTF-8?q?=EC=97=90=20=EC=A2=85=EB=AA=A9=EB=AA=85=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=E2=80=94=20trade=5Fhistory.stock=5Fname=20=EA=B3=B5=EB=B0=B1?= =?UTF-8?q?=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trade_history.stock_name 이 비어 있는 행이 있어 분석 화면의 최대 손실/수익 Top5 가 종목코드만 노출됐다. stock_analysis → portfolio 순으로 종목명을 보강 조회하고, 없으면 코드를 그대로 쓴다(부가 정보이므로 조회 실패는 warning 후 분석 계속). Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/services/trade_analyzer.py | 62 +++++++++++++++++++++++--- frontend/src/pages/Analysis.tsx | 24 ++++++---- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/backend/app/services/trade_analyzer.py b/backend/app/services/trade_analyzer.py index 4816831..86150ce 100644 --- a/backend/app/services/trade_analyzer.py +++ b/backend/app/services/trade_analyzer.py @@ -19,7 +19,7 @@ from decimal import Decimal from sqlalchemy import select, and_, func from sqlalchemy.ext.asyncio import AsyncSession -from app.models.models import TradeHistory, TradeJournal +from app.models.models import TradeHistory, TradeJournal, StockAnalysis, Portfolio from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -360,6 +360,39 @@ def _detect_drift_alerts(trades: List["TradeHistory"]) -> List[Dict[str, Any]]: # 핵심 분석 함수 # ───────────────────────────────────────────────────────────────────────────── +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일 완료 거래 성과 분석. @@ -370,8 +403,8 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any "by_strategy": {전략별 성과}, "by_weekday": {요일별 성과}, "by_exit_type": {청산 유형별 통계}, - "worst_stocks": [최대 손실 종목 Top5], - "best_stocks": [최대 수익 종목 Top5], + "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|), @@ -498,14 +531,33 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any # ── 종목별 집계 ────────────────────────────────────────────────────── 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_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks if p < 0][:5] - best_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[::-1] if p > 0][:5] + 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) diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index d5789bf..d5a59db 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -27,8 +27,8 @@ interface PerformanceData { summary: PerformanceSummary by_strategy: Record by_exit_type: Record - worst_stocks: { stock_code: string; net_pnl: number }[] - best_stocks: { stock_code: string; net_pnl: number }[] + worst_stocks: { stock_code: string; stock_name?: string; net_pnl: number; trade_count?: number }[] + best_stocks: { stock_code: string; stock_name?: string; net_pnl: number; trade_count?: number }[] period_days: number total_trades: number generated_at: string @@ -464,9 +464,13 @@ export default function Analysis() {

최대 손실 종목 Top5

{perf.worst_stocks.map((s) => ( -
- {s.stock_code} - {fmtKRW(s.net_pnl)} +
+ + {s.stock_name && s.stock_name !== s.stock_code ? s.stock_name : "종목명 미확인"} + {s.stock_code} + {s.trade_count ? {s.trade_count}건 : null} + + {fmtKRW(s.net_pnl)}
))}
@@ -475,9 +479,13 @@ export default function Analysis() {

최대 수익 종목 Top5

{perf.best_stocks.map((s) => ( -
- {s.stock_code} - 0 ? "text-green-400" : "text-red-400"}>{fmtKRW(s.net_pnl)} +
+ + {s.stock_name && s.stock_name !== s.stock_code ? s.stock_name : "종목명 미확인"} + {s.stock_code} + {s.trade_count ? {s.trade_count}건 : null} + + 0 ? "text-green-400" : "text-red-400"}`}>{fmtKRW(s.net_pnl)}
))}