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:
parent
ac0984ed04
commit
25115b33dd
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
71
docs/issues-2026-05-26.md
Normal 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 수동 실행.
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user