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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user