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] 상단 헤더 제목이 모든 서브 페이지에서 "대시보드"로 고정 +- **증상**: 헤더 `