From 25115b33dde5689962c12e512ba50e81d219e7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Tue, 26 May 2026 22:35:02 +0900 Subject: [PATCH] fix: resolve 5 site inspection issues (2026-05-26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 추가 --- backend/app/api/admin.py | 40 ++++++- .../collectors/etf_price_collector.py | 4 + backend/app/services/price_service.py | 62 +++++++--- docs/issues-2026-05-26.md | 71 ++++++++++++ .../src/components/charts/donut-chart.tsx | 109 ++++++++---------- frontend/src/components/layout/new-header.tsx | 8 ++ 6 files changed, 222 insertions(+), 72 deletions(-) create mode 100644 docs/issues-2026-05-26.md diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 28e2a2c..45b3c33 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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, diff --git a/backend/app/services/collectors/etf_price_collector.py b/backend/app/services/collectors/etf_price_collector.py index 50c9311..a96fd00 100644 --- a/backend/app/services/collectors/etf_price_collector.py +++ b/backend/app/services/collectors/etf_price_collector.py @@ -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: diff --git a/backend/app/services/price_service.py b/backend/app/services/price_service.py index 6270a64..dcdfbfb 100644 --- a/backend/app/services/price_service.py +++ b/backend/app/services/price_service.py @@ -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, diff --git a/docs/issues-2026-05-26.md b/docs/issues-2026-05-26.md new file mode 100644 index 0000000..5b4dfee --- /dev/null +++ b/docs/issues-2026-05-26.md @@ -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] 상단 헤더 제목이 모든 서브 페이지에서 "대시보드"로 고정 +- **증상**: 헤더 `` 또는 상단 텍스트가 페이지에 무관하게 "대시보드"로 표시됨 +- **영향 페이지**: 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 수동 실행. diff --git a/frontend/src/components/charts/donut-chart.tsx b/frontend/src/components/charts/donut-chart.tsx index ef7c803..efbfa1a 100644 --- a/frontend/src/components/charts/donut-chart.tsx +++ b/frontend/src/components/charts/donut-chart.tsx @@ -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> ); } diff --git a/frontend/src/components/layout/new-header.tsx b/frontend/src/components/layout/new-header.tsx index ebf68d8..04bd6a6 100644 --- a/frontend/src/components/layout/new-header.tsx +++ b/frontend/src/components/layout/new-header.tsx @@ -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 {