fix: resolve 5 site inspection issues (2026-05-26)

ISSUE-1: ETFPriceCollector 좀비 프로세스 재발 방지
- etf_price_collector: 루프마다 heartbeat() 호출 추가
- admin API: POST /api/admin/collect/reset-stuck 엔드포인트 추가

ISSUE-3: 헤더 제목 "대시보드" 고정 버그
- new-header.tsx pageTitles에 누락된 7개 경로 추가

ISSUE-4: 대시보드 파이 차트 미렌더링
- DonutChart Legend를 Recharts 외부로 분리하여 파이 공간 확보

ISSUE-5: daily_snapshots records_count 항상 0
- PriceService에 ETFPrice 테이블 fallback 추가
This commit is contained in:
머니페니 2026-05-26 22:35:02 +09:00
parent ac0984ed04
commit 25115b33dd
6 changed files with 222 additions and 72 deletions

View File

@ -8,7 +8,7 @@ creates its own DB session so the request can return immediately.
import logging
import threading
from typing import List, Optional
from datetime import datetime
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
@ -17,6 +17,7 @@ from pydantic import BaseModel
from app.core.database import get_db, SessionLocal
from app.api.deps import CurrentUser
from app.models.stock import JobLog
from app.services.collectors.base import HEARTBEAT_STALE_MINUTES
from app.services.collectors import (
StockCollector,
SectorCollector,
@ -161,6 +162,43 @@ async def collect_backfill(
return CollectResponse(message=f"Backfill started from {start_year}")
class ResetStuckResponse(BaseModel):
reset_count: int
message: str
@router.post("/collect/reset-stuck", response_model=ResetStuckResponse)
async def reset_stuck_jobs(
current_user: CurrentUser,
db: Session = Depends(get_db),
stale_minutes: int = Query(
HEARTBEAT_STALE_MINUTES,
gt=0,
le=1440,
description="Mark as failed if heartbeat is older than this many minutes",
),
):
"""Reset jobs stuck in 'running' state with a stale heartbeat."""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
stuck_jobs = (
db.query(JobLog)
.filter(
JobLog.status == "running",
(JobLog.last_heartbeat < cutoff) | (JobLog.last_heartbeat == None), # noqa: E711
)
.all()
)
for job in stuck_jobs:
job.status = "failed"
job.finished_at = datetime.now(timezone.utc)
job.error_msg = f"Reset by admin: no heartbeat for >{stale_minutes}min"
db.commit()
return ResetStuckResponse(
reset_count=len(stuck_jobs),
message=f"Reset {len(stuck_jobs)} stuck job(s)",
)
@router.get("/collect/status", response_model=List[JobLogResponse])
async def get_collection_status(
current_user: CurrentUser,

View File

@ -135,6 +135,7 @@ class ETFPriceCollector(BaseCollector):
self.db.rollback()
logger.warning(f"ETF price fetch for {date_str} via Open API failed: {e}")
self.heartbeat()
current += timedelta(days=1)
return total_records
@ -214,6 +215,7 @@ class ETFPriceCollector(BaseCollector):
except (JSONDecodeError, Exception) as e:
self.db.rollback()
logger.warning(f"Both price fetches failed for ETF {ticker}: {e}")
self.heartbeat()
continue
if records:
@ -234,6 +236,8 @@ class ETFPriceCollector(BaseCollector):
self.db.rollback()
logger.warning(f"Failed to upsert ETF prices for {ticker}: {e}")
self.heartbeat()
return total_records
def _has_records_for_end_date(self) -> bool:

View File

@ -11,7 +11,7 @@ from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models.stock import Price
from app.models.stock import Price, ETFPrice
class PriceData:
@ -51,7 +51,7 @@ class PriceService:
"""
Get current price for a single ticker.
Returns the most recent closing price from DB.
Checks Price (stocks) then ETFPrice as fallback.
"""
result = (
self.db.query(Price.close)
@ -59,19 +59,31 @@ class PriceService:
.order_by(Price.date.desc())
.first()
)
return Decimal(str(result[0])) if result else None
if result:
return Decimal(str(result[0]))
etf_result = (
self.db.query(ETFPrice.close)
.filter(ETFPrice.ticker == ticker)
.order_by(ETFPrice.date.desc())
.first()
)
return Decimal(str(etf_result[0])) if etf_result else None
def get_current_prices(self, tickers: List[str]) -> Dict[str, Decimal]:
"""
Get current prices for multiple tickers.
Returns a dict mapping ticker to most recent closing price.
Checks the Price table (stocks) first, then ETFPrice for any
tickers not found there. Returns the most recent closing price.
"""
if not tickers:
return {}
# Subquery to get max date for each ticker
subquery = (
prices: Dict[str, Decimal] = {}
# --- Stock prices ---
stock_subq = (
self.db.query(
Price.ticker,
func.max(Price.date).label('max_date')
@ -80,19 +92,43 @@ class PriceService:
.group_by(Price.ticker)
.subquery()
)
# Get prices at max date
results = (
stock_rows = (
self.db.query(Price.ticker, Price.close)
.join(
subquery,
(Price.ticker == subquery.c.ticker) &
(Price.date == subquery.c.max_date)
stock_subq,
(Price.ticker == stock_subq.c.ticker) &
(Price.date == stock_subq.c.max_date)
)
.all()
)
for ticker, close in stock_rows:
prices[ticker] = Decimal(str(close))
return {ticker: Decimal(str(close)) for ticker, close in results}
# --- ETF prices (fallback for tickers not in Price table) ---
missing = [t for t in tickers if t not in prices]
if missing:
etf_subq = (
self.db.query(
ETFPrice.ticker,
func.max(ETFPrice.date).label('max_date')
)
.filter(ETFPrice.ticker.in_(missing))
.group_by(ETFPrice.ticker)
.subquery()
)
etf_rows = (
self.db.query(ETFPrice.ticker, ETFPrice.close)
.join(
etf_subq,
(ETFPrice.ticker == etf_subq.c.ticker) &
(ETFPrice.date == etf_subq.c.max_date)
)
.all()
)
for ticker, close in etf_rows:
prices[ticker] = Decimal(str(close))
return prices
def get_price_history(
self,

71
docs/issues-2026-05-26.md Normal file
View File

@ -0,0 +1,71 @@
# 사이트 점검 이슈 목록 (2026-05-26)
점검 URL: https://galaxis.ayuriel.duckdns.org/
점검 일시: 2026-05-26
---
## 🔴 심각 (즉시 처리)
### [ISSUE-1] ETFPriceCollector 좀비 프로세스 + 오늘 배치 미실행
- **증상**: `ETFPriceCollector`가 2026-05-25 오전 9:00:32부터 `running` 상태로 24시간 이상 멈혀 있음
- **영향**:
- 오늘(2026-05-26) 오전 9시 수집 배치 전체 미실행 (StockCollector, PriceCollector, ValuationCollector, SectorCollector, ETFCollector, ETFPriceCollector)
- KJB 스크리닝 오늘 신호 0건
- **확인 위치**: 데이터 수집 페이지 → 최근 작업 이력
- **처리 방법**:
1. 좀비 프로세스 강제 종료 (DB job 레코드 상태를 `failed`로 업데이트 또는 프로세스 kill)
2. 오늘 수집 배치 수동 실행 (데이터 수집 페이지 각 항목 "실행" 버튼 또는 백엔드 직접 호출)
3. 원인 분석: 왜 ETFPriceCollector가 멈췄는지 로그 확인
### [ISSUE-2] 데이터 탐색 메뉴 → 404
- **증상**: 사이드바 "데이터 탐색" 클릭 시 `/admin/data/explorer` → 404 오류
- **처리 방법**: 해당 라우트/페이지가 미구현이거나 경로 변경된 것 — 프론트엔드 라우트 확인 및 복구
---
## 🟠 UI 버그
### [ISSUE-3] 상단 헤더 제목이 모든 서브 페이지에서 "대시보드"로 고정
- **증상**: 헤더 `<title>` 또는 상단 텍스트가 페이지에 무관하게 "대시보드"로 표시됨
- **영향 페이지**: KJB 스크리닝, 매매 신호, AI 에이전트, 트레이딩 저널, 퇴직연금, 포지션 사이징, 설정
- **정상 페이지**: 대시보드, 전략, 백테스트, 데이터 수집
- **처리 방법**: 레이아웃 컴포넌트에서 현재 경로에 따라 헤더 제목을 동적으로 설정하는 로직 수정
### [ISSUE-4] 대시보드 자산 배분 파이 차트 미렌더링
- **증상**: 자산 배분 섹션에 범례(KODEX 200미국채혼합 45.4% 등)만 표시, 파이 차트가 빈 공간
- **처리 방법**: 차트 라이브러리 렌더링 오류 또는 데이터 형식 문제 확인 (콘솔 에러 확인 필요)
---
## 🟡 경미 (확인 필요)
### [ISSUE-5] `daily_snapshots` 건수 항상 0
- **증상**: 매일 `success` 상태로 실행되지만 건수가 항상 0
- **처리 방법**: 정상 동작인지(스냅샷 쿼리 결과가 0인 게 맞는지) 또는 실제 버그인지 로직 확인
---
## 처리 현황
| ID | 항목 | 우선순위 | 상태 |
|----|------|----------|------|
| ISSUE-1 | ETFPriceCollector 좀비 + 배치 미실행 | 🔴 즉시 | ✅ 코드 수정 완료 (운영 DB 조치 별도 필요) |
| ISSUE-2 | 데이터 탐색 404 | 🔴 높음 | ✅ 코드 정상 확인 (배포 재빌드로 해결) |
| ISSUE-3 | 헤더 제목 "대시보드" 고정 버그 | 🟠 중간 | ✅ new-header.tsx pageTitles 추가 |
| ISSUE-4 | 대시보드 파이 차트 미렌더링 | 🟠 중간 | ✅ DonutChart Legend 분리 수정 |
| ISSUE-5 | daily_snapshots 건수 0 | 🟡 낮음 | ✅ PriceService ETFPrice fallback 추가 |
## ISSUE-1 운영 조치 (서버에서 직접 실행)
```sql
-- 좀비 ETFPriceCollector 레코드 강제 종료
UPDATE job_logs
SET status = 'failed', finished_at = NOW(),
error_msg = 'Manually killed: stuck for 24h+'
WHERE job_name = 'ETFPriceCollector' AND status = 'running';
```
또는 새 API: `POST /api/admin/collect/reset-stuck`
이후 데이터 수집 페이지에서 각 collector 수동 실행.

View File

@ -2,7 +2,6 @@
import {
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
@ -42,65 +41,59 @@ export function DonutChart({
const total = data.reduce((sum, item) => sum + item.value, 0);
const renderLegend = () => {
return (
<ul className="flex flex-col gap-2 text-sm">
{data.map((entry, index) => (
<li key={`legend-${index}`} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-foreground">{entry.name}</span>
<span className="text-muted-foreground ml-auto">
{((entry.value / total) * 100).toFixed(1)}%
</span>
</li>
))}
</ul>
);
};
return (
<div className={className} style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
color: 'hsl(var(--popover-foreground))',
}}
formatter={(value, name) => [
`${(((value as number) / total) * 100).toFixed(1)}%`,
name,
]}
/>
{showLegend && (
<Legend
content={renderLegend}
layout="vertical"
align="right"
verticalAlign="middle"
<div className={`flex gap-4 items-center ${className ?? ''}`} style={{ height }}>
{/* Chart — fixed width so the pie always has room */}
<div style={{ flexShrink: 0, width: outerRadius * 2 + 20, height: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
color: 'hsl(var(--popover-foreground))',
}}
formatter={(value, name) => [
`${(((value as number) / total) * 100).toFixed(1)}%`,
name,
]}
/>
)}
</PieChart>
</ResponsiveContainer>
</PieChart>
</ResponsiveContainer>
</div>
{/* Legend — outside Recharts so long names never squeeze the pie */}
{showLegend && (
<ul className="flex flex-col gap-2 text-sm flex-1 overflow-hidden">
{data.map((entry, index) => (
<li key={`legend-${index}`} className="flex items-center gap-2 min-w-0">
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: entry.color }}
/>
<span className="text-foreground truncate">{entry.name}</span>
<span className="text-muted-foreground ml-auto shrink-0">
{((entry.value / total) * 100).toFixed(1)}%
</span>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -10,8 +10,16 @@ const pageTitles: Record<string, string> = {
'/portfolio': '포트폴리오',
'/strategy': '전략',
'/backtest': '백테스트',
'/screening': 'KJB 스크리닝',
'/signals': '매매 신호',
'/agent': 'AI 에이전트',
'/journal': '트레이딩 저널',
'/pension': '퇴직연금',
'/tools/position-sizing': '포지션 사이징',
'/admin/data': '데이터 수집',
'/admin/data/explorer': '데이터 탐색',
'/settings': '설정',
'/settings/notifications': '설정',
};
function getPageTitle(pathname: string): string {