feat(analysis): 손익 Top5 에 종목명 표시 — trade_history.stock_name 공백 보강

trade_history.stock_name 이 비어 있는 행이 있어 분석 화면의 최대 손실/수익
Top5 가 종목코드만 노출됐다. stock_analysis → portfolio 순으로 종목명을
보강 조회하고, 없으면 코드를 그대로 쓴다(부가 정보이므로 조회 실패는
warning 후 분석 계속).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 11:51:45 +09:00
parent 43b4b1a7aa
commit 418371e1c8
2 changed files with 73 additions and 13 deletions

View File

@@ -19,7 +19,7 @@ from decimal import Decimal
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.core.timeutil import now_kst
logger = logging.getLogger(__name__) 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]: async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any]:
""" """
최근 N일 완료 거래 성과 분석. 최근 N일 완료 거래 성과 분석.
@@ -370,8 +403,8 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
"by_strategy": {전략별 성과}, "by_strategy": {전략별 성과},
"by_weekday": {요일별 성과}, "by_weekday": {요일별 성과},
"by_exit_type": {청산 유형별 통계}, "by_exit_type": {청산 유형별 통계},
"worst_stocks": [최대 손실 종목 Top5], "worst_stocks": [최대 손실 종목 Top5 {stock_code, stock_name, net_pnl, trade_count}],
"best_stocks": [최대 수익 종목 Top5], "best_stocks": [최대 수익 종목 Top5 {stock_code, stock_name, net_pnl, trade_count}],
"sharpe_ratio": float | None (연환산, Rf=3%), "sharpe_ratio": float | None (연환산, Rf=3%),
"sortino_ratio": float | None (하방편차만), "sortino_ratio": float | None (하방편차만),
"calmar_ratio": float | None (CAGR / |MDD|), "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_pnl: Dict[str, float] = {}
stock_names: Dict[str, str] = {}
stock_trade_count: Dict[str, int] = {}
for t in trades: for t in trades:
code = t.stock_code code = t.stock_code
stock_pnl[code] = stock_pnl.get(code, 0.0) + _to_float(t.net_profit_loss) 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]) 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] worst_stocks = [_stock_row(c, p) 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] best_stocks = [_stock_row(c, p) for c, p in sorted_stocks[::-1] if p > 0][:5]
# ── 리스크 조정 수익률 지표 ──────────────────────────────────────────── # ── 리스크 조정 수익률 지표 ────────────────────────────────────────────
risk_metrics = _calc_risk_adjusted_metrics(trades) risk_metrics = _calc_risk_adjusted_metrics(trades)

View File

@@ -27,8 +27,8 @@ interface PerformanceData {
summary: PerformanceSummary summary: PerformanceSummary
by_strategy: Record<string, { total: number; win_rate: number; avg_return: number }> by_strategy: Record<string, { total: number; win_rate: number; avg_return: number }>
by_exit_type: Record<string, { count: number; avg_return: number; win_rate: number }> by_exit_type: Record<string, { count: number; avg_return: number; win_rate: number }>
worst_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; net_pnl: number }[] best_stocks: { stock_code: string; stock_name?: string; net_pnl: number; trade_count?: number }[]
period_days: number period_days: number
total_trades: number total_trades: number
generated_at: string generated_at: string
@@ -464,9 +464,13 @@ export default function Analysis() {
<h2 className="text-base font-semibold text-red-400 mb-3">최대 손실 종목 Top5</h2> <h2 className="text-base font-semibold text-red-400 mb-3">최대 손실 종목 Top5</h2>
<div className="space-y-2"> <div className="space-y-2">
{perf.worst_stocks.map((s) => ( {perf.worst_stocks.map((s) => (
<div key={s.stock_code} className="flex justify-between text-sm"> <div key={s.stock_code} className="flex justify-between items-baseline gap-2 text-sm">
<span className="text-gray-200">{s.stock_code}</span> <span className="text-gray-200 truncate">
<span className={s.net_pnl < 0 ? "text-red-400" : "text-green-400"}>{fmtKRW(s.net_pnl)}</span> {s.stock_name && s.stock_name !== s.stock_code ? s.stock_name : "종목명 미확인"}
<span className="ml-1.5 text-xs text-gray-500 font-mono">{s.stock_code}</span>
{s.trade_count ? <span className="ml-1.5 text-xs text-gray-500">{s.trade_count}건</span> : null}
</span>
<span className={`shrink-0 ${s.net_pnl < 0 ? "text-red-400" : "text-green-400"}`}>{fmtKRW(s.net_pnl)}</span>
</div> </div>
))} ))}
</div> </div>
@@ -475,9 +479,13 @@ export default function Analysis() {
<h2 className="text-base font-semibold text-green-400 mb-3">최대 수익 종목 Top5</h2> <h2 className="text-base font-semibold text-green-400 mb-3">최대 수익 종목 Top5</h2>
<div className="space-y-2"> <div className="space-y-2">
{perf.best_stocks.map((s) => ( {perf.best_stocks.map((s) => (
<div key={s.stock_code} className="flex justify-between text-sm"> <div key={s.stock_code} className="flex justify-between items-baseline gap-2 text-sm">
<span className="text-gray-200">{s.stock_code}</span> <span className="text-gray-200 truncate">
<span className={s.net_pnl > 0 ? "text-green-400" : "text-red-400"}>{fmtKRW(s.net_pnl)}</span> {s.stock_name && s.stock_name !== s.stock_code ? s.stock_name : "종목명 미확인"}
<span className="ml-1.5 text-xs text-gray-500 font-mono">{s.stock_code}</span>
{s.trade_count ? <span className="ml-1.5 text-xs text-gray-500">{s.trade_count}건</span> : null}
</span>
<span className={`shrink-0 ${s.net_pnl > 0 ? "text-green-400" : "text-red-400"}`}>{fmtKRW(s.net_pnl)}</span>
</div> </div>
))} ))}
</div> </div>