From 120a8546cb3ecdf41732e42b836b3eeb8116d878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Sun, 10 May 2026 16:44:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EB=B3=B4=EC=99=84=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026_05_10_job_log_heartbeat_orphan.py | 37 ++ backend/app/api/data_explorer.py | 47 +- backend/app/models/stock.py | 1 + backend/app/services/backtest/__init__.py | 3 +- backend/app/services/backtest/engine.py | 27 +- backend/app/services/backtest/worker.py | 15 +- backend/app/services/collectors/base.py | 29 +- .../collectors/etf_price_collector.py | 99 ++-- backend/jobs/kjb_signal_job.py | 13 +- ...2026-05-09-test-report-remediation-plan.md | 219 ++++++++ docs/test-report-2026-05-09.md | 140 ++++++ frontend/src/app/admin/data/explorer/page.tsx | 14 +- frontend/src/app/admin/data/page.tsx | 15 +- frontend/src/app/admin/page.tsx | 5 + frontend/src/app/agent/page.tsx | 288 +++++++++++ frontend/src/app/backtest/page.tsx | 67 ++- frontend/src/app/pension/page.tsx | 19 +- frontend/src/app/portfolio/[id]/page.tsx | 164 +++--- frontend/src/app/screening/page.tsx | 474 ++++++++++++++++++ frontend/src/app/strategy/page.tsx | 31 +- frontend/src/components/error-boundary.tsx | 17 + .../src/components/layout/new-sidebar.tsx | 4 + frontend/src/components/ui/textarea.tsx | 22 + frontend/src/lib/api.ts | 51 ++ 24 files changed, 1637 insertions(+), 164 deletions(-) create mode 100644 backend/alembic/versions/2026_05_10_job_log_heartbeat_orphan.py create mode 100644 docs/plans/2026-05-09-test-report-remediation-plan.md create mode 100644 docs/test-report-2026-05-09.md create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/agent/page.tsx create mode 100644 frontend/src/app/screening/page.tsx create mode 100644 frontend/src/components/ui/textarea.tsx diff --git a/backend/alembic/versions/2026_05_10_job_log_heartbeat_orphan.py b/backend/alembic/versions/2026_05_10_job_log_heartbeat_orphan.py new file mode 100644 index 0000000..ee5fb5e --- /dev/null +++ b/backend/alembic/versions/2026_05_10_job_log_heartbeat_orphan.py @@ -0,0 +1,37 @@ +"""Add last_heartbeat to job_logs and mark orphaned running jobs as failed + +Revision ID: 2026_05_10_job_log_heartbeat_orphan +Revises: f6a7b8c9d0e1 +Create Date: 2026-05-10 + +""" +from alembic import op +import sqlalchemy as sa +from datetime import datetime, timedelta, timezone + +revision = '2026_05_10_job_log_heartbeat_orphan' +down_revision = 'f6a7b8c9d0e1' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Add last_heartbeat column + op.add_column('job_logs', sa.Column('last_heartbeat', sa.DateTime(), nullable=True)) + + # 2. Mark all currently-running jobs that haven't been updated recently as failed_orphaned + # (any job still 'running' at migration time is stale) + op.execute( + """ + UPDATE job_logs + SET status = 'failed_orphaned', + finished_at = NOW(), + error_msg = 'Process terminated without updating status (orphaned)' + WHERE status = 'running' + """ + ) + + +def downgrade() -> None: + op.drop_column('job_logs', 'last_heartbeat') + # No rollback for the status update — data-only change diff --git a/backend/app/api/data_explorer.py b/backend/app/api/data_explorer.py index 08f121a..7dd3e2e 100644 --- a/backend/app/api/data_explorer.py +++ b/backend/app/api/data_explorer.py @@ -5,7 +5,7 @@ from datetime import date from typing import Optional, List from fastapi import APIRouter, Depends, Query -from sqlalchemy import or_ +from sqlalchemy import or_, func from sqlalchemy.orm import Session from pydantic import BaseModel @@ -93,6 +93,19 @@ class ValuationItem(BaseModel): from_attributes = True +class PriceSeries(BaseModel): + items: List[PriceItem] + total: int + skip: int + limit: int + + +class PriceCoverage(BaseModel): + available_from: date | None = None + available_to: date | None = None + distinct_days: int = 0 + + # --- Endpoints --- @router.get("/stocks") @@ -124,7 +137,25 @@ async def list_stocks( } -@router.get("/stocks/{ticker}/prices") +@router.get("/prices/coverage", response_model=PriceCoverage) +async def get_price_coverage( + current_user: CurrentUser, + db: Session = Depends(get_db), +): + """Return min/max date and distinct-day count across the Price table.""" + row = db.query( + func.min(Price.date), + func.max(Price.date), + func.count(func.distinct(Price.date)), + ).one() + return PriceCoverage( + available_from=row[0], + available_to=row[1], + distinct_days=row[2] or 0, + ) + + +@router.get("/stocks/{ticker}/prices", response_model=PriceSeries) async def get_stock_prices( ticker: str, current_user: CurrentUser, @@ -142,12 +173,12 @@ async def get_stock_prices( .limit(limit) .all() ) - return { - "items": [PriceItem.model_validate(p) for p in prices], - "total": total, - "skip": skip, - "limit": limit, - } + return PriceSeries( + items=[PriceItem.model_validate(p) for p in prices], + total=total, + skip=skip, + limit=limit, + ) @router.get("/etfs") diff --git a/backend/app/models/stock.py b/backend/app/models/stock.py index bd39be2..64e552a 100644 --- a/backend/app/models/stock.py +++ b/backend/app/models/stock.py @@ -136,3 +136,4 @@ class JobLog(Base): finished_at = Column(DateTime, nullable=True) records_count = Column(Integer, nullable=True) error_msg = Column(Text, nullable=True) + last_heartbeat = Column(DateTime, nullable=True) diff --git a/backend/app/services/backtest/__init__.py b/backend/app/services/backtest/__init__.py index b9df97b..8c88ddd 100644 --- a/backend/app/services/backtest/__init__.py +++ b/backend/app/services/backtest/__init__.py @@ -1,4 +1,4 @@ -from app.services.backtest.engine import BacktestEngine, DataValidationResult +from app.services.backtest.engine import BacktestEngine, DataValidationResult, BacktestNoDataError from app.services.backtest.portfolio import VirtualPortfolio, Transaction, HoldingInfo from app.services.backtest.metrics import MetricsCalculator, BacktestMetrics from app.services.backtest.worker import submit_backtest, get_executor_status @@ -9,6 +9,7 @@ from app.services.backtest.walkforward_engine import WalkForwardEngine __all__ = [ "BacktestEngine", "DataValidationResult", + "BacktestNoDataError", "VirtualPortfolio", "Transaction", "HoldingInfo", diff --git a/backend/app/services/backtest/engine.py b/backend/app/services/backtest/engine.py index 701bc3b..967eaa9 100644 --- a/backend/app/services/backtest/engine.py +++ b/backend/app/services/backtest/engine.py @@ -33,6 +33,20 @@ from app.schemas.strategy import UniverseFilter, FactorWeights logger = logging.getLogger(__name__) +class BacktestNoDataError(Exception): + """Raised when the Price table contains no data for the requested period.""" + + def __init__(self, start: date, end: date, available_from: date | None, available_to: date | None): + self.start = start + self.end = end + self.available_from = available_from + self.available_to = available_to + msg = f"No trading days found between {start} and {end}." + if available_from and available_to: + msg += f" Available range: {available_from} ~ {available_to}." + super().__init__(msg) + + @dataclass class DataValidationResult: """Result of pre-backtest data validation.""" @@ -73,7 +87,18 @@ class BacktestEngine: ) if not trading_days: - raise ValueError("No trading days found in the specified period") + # Fetch available range to give meaningful feedback + from sqlalchemy import func as _func + row = self.db.query( + _func.min(Price.date), + _func.max(Price.date), + ).one() + raise BacktestNoDataError( + start=backtest.start_date, + end=backtest.end_date, + available_from=row[0], + available_to=row[1], + ) # Load benchmark data benchmark_prices = self._load_benchmark_prices( diff --git a/backend/app/services/backtest/worker.py b/backend/app/services/backtest/worker.py index da965a9..bc1fcc1 100644 --- a/backend/app/services/backtest/worker.py +++ b/backend/app/services/backtest/worker.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from app.core.database import SessionLocal from app.models.backtest import Backtest, BacktestStatus -from app.services.backtest.engine import BacktestEngine +from app.services.backtest.engine import BacktestEngine, BacktestNoDataError logger = logging.getLogger(__name__) @@ -58,6 +58,19 @@ def _run_backtest_job(backtest_id: int) -> None: db.commit() logger.info(f"Backtest {backtest_id} completed successfully") + except BacktestNoDataError as e: + logger.warning(f"Backtest {backtest_id} — no price data: {e}") + try: + backtest = db.get(Backtest, backtest_id) + if backtest: + backtest.status = BacktestStatus.FAILED + msg = f"no_price_data: {e.available_from} ~ {e.available_to}" if e.available_from else "no_price_data: 가격 데이터 없음" + backtest.error_message = msg + backtest.completed_at = datetime.now(timezone.utc) + db.commit() + except Exception as commit_error: + logger.exception(f"Failed to update backtest status: {commit_error}") + except Exception as e: logger.exception(f"Backtest {backtest_id} failed: {e}") diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index d5414f1..1de8537 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -4,8 +4,9 @@ Base collector class for data collection jobs. import logging import re from abc import ABC, abstractmethod +from contextlib import contextmanager from datetime import datetime, timezone -from typing import Optional +from typing import Optional, Generator import requests from bs4 import BeautifulSoup @@ -15,6 +16,8 @@ from app.models.stock import JobLog logger = logging.getLogger(__name__) +HEARTBEAT_STALE_MINUTES = 30 + class BaseCollector(ABC): """Base class for all data collectors.""" @@ -45,11 +48,21 @@ class BaseCollector(ABC): job_name=self.job_name, status="running", started_at=datetime.now(timezone.utc), + last_heartbeat=datetime.now(timezone.utc), ) self.db.add(self.job_log) self.db.commit() return self.job_log + def heartbeat(self) -> None: + """Update last_heartbeat so watchdog knows the job is still alive.""" + if self.job_log: + try: + self.job_log.last_heartbeat = datetime.now(timezone.utc) + self.db.commit() + except Exception: + self.db.rollback() + def complete_job(self, records_count: int): """Mark job as completed.""" if self.job_log: @@ -74,6 +87,18 @@ class BaseCollector(ABC): self.db.rollback() raise + def complete_if_running(self) -> None: + """If the job is still 'running' in the DB, mark it failed_orphaned. + Called in finally blocks to handle unexpected termination paths.""" + if self.job_log and self.job_log.status == "running": + try: + self.job_log.status = "failed_orphaned" + self.job_log.finished_at = datetime.now(timezone.utc) + self.job_log.error_msg = "Job exited without explicit success/fail" + self.db.commit() + except Exception: + self.db.rollback() + @abstractmethod def collect(self) -> int: """ @@ -94,4 +119,6 @@ class BaseCollector(ABC): except Exception: pass # Log update failed, but original exception is more important raise + finally: + self.complete_if_running() return self.job_log diff --git a/backend/app/services/collectors/etf_price_collector.py b/backend/app/services/collectors/etf_price_collector.py index 48d08e0..73c1b99 100644 --- a/backend/app/services/collectors/etf_price_collector.py +++ b/backend/app/services/collectors/etf_price_collector.py @@ -129,7 +129,11 @@ class ETFPriceCollector(BaseCollector): return total_records def _collect_pykrx(self) -> int: - """Collect ETF prices via pykrx scraping (ticker-based loop).""" + """Collect ETF prices via pykrx scraping. + + ETFs are traded on the stock market, so get_market_ohlcv works + reliably. Falls back to get_etf_ohlcv_by_date if needed. + """ from pykrx import stock as pykrx_stock tickers = self.db.query(ETF.ticker).all() @@ -143,34 +147,66 @@ class ETFPriceCollector(BaseCollector): logger.info(f"Collecting ETF prices for {len(ticker_list)} ETFs from {self.start_date} to {self.end_date}") for ticker in ticker_list: + records = [] try: - df = pykrx_stock.get_etf_ohlcv_by_date( + # Primary: use market OHLCV (same API as stock prices, works without KRX login) + df = pykrx_stock.get_market_ohlcv( self.start_date, self.end_date, ticker ) - if df.empty: + if df is not None and not df.empty: + df = df.reset_index() + # Column names: 날짜, 시가, 고가, 저가, 종가, 거래량, 거래대금 + for _, row in df.iterrows(): + close_val = self._safe_float( + row.get("종가") or (row.iloc[4] if len(row) > 4 else None) + ) + if close_val is None: + continue + volume_val = self._safe_int( + row.get("거래량") or (row.iloc[5] if len(row) > 5 else None) + ) + date_col = row.get("날짜") or row.iloc[0] + date_value = date_col.date() if hasattr(date_col, "date") else date_col + records.append({ + "ticker": ticker, + "date": date_value, + "close": close_val, + "nav": None, + "volume": volume_val, + }) + except (JSONDecodeError, Exception) as e: + logger.debug(f"market_ohlcv failed for ETF {ticker}: {e}, trying etf_ohlcv fallback") + + if not records: + # Fallback: ETF-specific API (may require KRX login) + try: + df = pykrx_stock.get_etf_ohlcv_by_date( + self.start_date, self.end_date, ticker + ) + if df is not None and not df.empty: + df = df.reset_index() + for _, row in df.iterrows(): + close_val = self._safe_float(row.get("종가")) + if close_val is None: + continue + nav_val = self._safe_float(row.get("NAV")) + volume_val = self._safe_int(row.get("거래량")) + date_col = row.get("날짜") or row.iloc[0] + date_value = date_col.date() if hasattr(date_col, "date") else date_col + records.append({ + "ticker": ticker, + "date": date_value, + "close": close_val, + "nav": nav_val, + "volume": volume_val, + }) + except (JSONDecodeError, Exception) as e: + self.db.rollback() + logger.warning(f"Both price fetches failed for ETF {ticker}: {e}") continue - df = df.reset_index() - - records = [] - for _, row in df.iterrows(): - close_val = self._safe_float(row.get("종가")) - if close_val is None: - continue - - nav_val = self._safe_float(row.get("NAV")) - volume_val = self._safe_int(row.get("거래량")) - date_value = row["날짜"].date() if hasattr(row["날짜"], "date") else row["날짜"] - - records.append({ - "ticker": ticker, - "date": date_value, - "close": close_val, - "nav": nav_val, - "volume": volume_val, - }) - - if records: + if records: + try: stmt = insert(ETFPrice).values(records) stmt = stmt.on_conflict_do_update( index_elements=["ticker", "date"], @@ -183,18 +219,9 @@ class ETFPriceCollector(BaseCollector): self.db.execute(stmt) self.db.commit() total_records += len(records) - - except JSONDecodeError as e: - self.db.rollback() - logger.warning( - f"ETF price fetch for {ticker}: JSON decode error ({e}). " - "KRX may require login — set KRX_ID/KRX_PW env vars." - ) - continue - except Exception as e: - self.db.rollback() - logger.warning(f"Failed to fetch ETF prices for {ticker}: {e}") - continue + except Exception as e: + self.db.rollback() + logger.warning(f"Failed to upsert ETF prices for {ticker}: {e}") return total_records diff --git a/backend/jobs/kjb_signal_job.py b/backend/jobs/kjb_signal_job.py index 777f884..2ff5900 100644 --- a/backend/jobs/kjb_signal_job.py +++ b/backend/jobs/kjb_signal_job.py @@ -8,7 +8,7 @@ from datetime import date, timedelta import pandas as pd from app.core.database import SessionLocal -from app.models.stock import Stock, Price +from app.models.stock import Stock, Price, ETFPrice from app.models.signal import Signal, SignalType, SignalStatus from app.services.strategy.kjb import KJBSignalGenerator from app.services.notification import send_notification @@ -39,15 +39,16 @@ def run_kjb_signals(): name_map = {s.ticker: s.name for s in stocks} lookback_start = today - timedelta(days=90) + # KOSPI benchmark: use ETF prices table (069500 = KODEX 200) kospi_prices = ( - db.query(Price) - .filter(Price.ticker == "069500") - .filter(Price.date >= lookback_start, Price.date <= today) - .order_by(Price.date) + db.query(ETFPrice) + .filter(ETFPrice.ticker == "069500") + .filter(ETFPrice.date >= lookback_start, ETFPrice.date <= today) + .order_by(ETFPrice.date) .all() ) if not kospi_prices: - logger.warning("No KOSPI data available for signal generation") + logger.warning("No KOSPI ETF data (069500) in etf_prices table. Run ETFPriceCollector first.") return kospi_df = pd.DataFrame([ diff --git a/docs/plans/2026-05-09-test-report-remediation-plan.md b/docs/plans/2026-05-09-test-report-remediation-plan.md new file mode 100644 index 0000000..75f57ed --- /dev/null +++ b/docs/plans/2026-05-09-test-report-remediation-plan.md @@ -0,0 +1,219 @@ +# Test Report 2026-05-09 보완 계획 + +**작성일**: 2026-05-09 +**대상 리포트**: `docs/test-report-2026-05-09.md` +**상태**: 초안 +**작업 범위**: 버그 4건 + 미노출 페이지 7건 + 백엔드 전용 기능 2건 = 총 13건 + +--- + +## 0. 요약 + +테스트 리포트에서 발견된 결함과 미노출 기능을 P0(차단성 버그) → P1(주요 기능/UX) → P2(편의성) 순으로 처리한다. P0 버그 두 건은 사용자가 즉시 마주치는 화이트스크린/실패이므로 **1차 핫픽스 스프린트(1~2일)** 안에 끝내고, 미노출 기능 노출은 **2차 스프린트(3~5일)**, AI 에이전트와 KJB 스크리닝 신규 화면은 **3차 스프린트(1~2주)** 일정으로 분리한다. + +| 우선순위 | 항목 | 예상 공수 | 차단 여부 | +|---|---|---|---| +| P0 | BUG-01 백테스트 전면 실패 | 1d (UI 가드) + 별도 데이터 백필 잡 | 기능 차단 | +| P0 | BUG-02 데이터 탐색 차트 런타임 에러 | 0.5d | 기능 차단 | +| P1 | BUG-03 PriceCollector 상태 미업데이트 | 0.5d | 모니터링 신뢰도 | +| P2 | BUG-04 `/admin` 404 | 0.1d | 낮음 | +| P1 | 미노출 페이지 7종 진입 경로 | 1.5d | UX | +| P1 | AI 에이전트 프론트엔드 | 3~5d | 신규 기능 | +| P1 | KJB 스크리닝 프론트엔드 | 2~3d | 신규 기능 | + +--- + +## 1. P0 버그 — 핫픽스 스프린트 + +### BUG-01. 백테스트 전면 실패 (`No trading days found`) + +**근본 원인** +`backend/app/services/backtest/engine.py:316` 의 `_get_trading_days()`가 `Price` 테이블에 의존한다. 현재 DB에는 2025년 이후 가격만 적재되어 있어, 기본 백테스트 구간(2020~2024)에서 항상 빈 리스트를 반환하고 엔진이 그대로 예외를 던진다. + +**대응 — 2단계 동시 진행** + +1. **임시 가드(즉시 배포 대상, 0.5d)** + - 백테스트 라우터/엔진에서 `_get_trading_days()` 결과가 비면 명확한 도메인 에러로 변환: + - `BacktestNoDataError(start, end, available_range)` 신규 예외 추가. + - `app/api/backtest.py` 핸들러에서 422 + `{ "code": "no_price_data", "available_from": ..., "available_to": ... }` 응답. + - 프론트엔드 `frontend/src/app/backtest/page.tsx`: + - 페이지 진입 시 `/api/data/prices/coverage` (신규, 아래 항목 참조) 호출하여 사용 가능한 최소·최대 날짜 조회. + - DatePicker `min`/`max`를 해당 범위로 제한하고 placeholder를 "사용 가능: YYYY-MM-DD ~ YYYY-MM-DD" 로 표시. + - 422 응답 시 토스트 + 인라인 안내문. + - 백엔드 신규 엔드포인트 `GET /api/data/prices/coverage` → `min(date)`, `max(date)`, `count(distinct date)` 반환. + +2. **데이터 백필(별도 백그라운드 잡, 0.5~1d 작업 + 수 시간 실행)** + - `app/services/collectors/price_collector.py`에 `backfill(start_date, end_date, tickers=None)` 모드 추가 (기존 `pykrx` 일별 수집 로직 재활용, 페이징/재시도 포함). + - `app/jobs/`에 일회성 트리거 `run_price_backfill_job` 작성. APScheduler 등록 대신 admin API `POST /api/admin/jobs/price-backfill` 으로 수동 실행. + - 실행 로그를 `data_collection_jobs` 테이블에 누적 (BUG-03 처리 결과 활용). + - 검증: 백필 완료 후 2020-01-02 ~ 2024-12-30 거래일 약 1,230일이 `Price.date.distinct()`에 존재. + +**검증 기준** +- 임시 가드: 사용자가 빈 데이터 구간을 고르면 422 + "2025-01-02 이후 데이터만 사용 가능합니다" 메시지가 뜨고, 가능한 구간을 선택하면 백테스트가 끝까지 실행된다. +- 백필: 데이터 수집 페이지에서 Job 성공 표시, `select count(distinct date) from price where date < '2025-01-01'` 가 1,000건 이상. +- e2e 테스트 추가: `tests/e2e/test_backtest_no_data.py` (빈 구간 → 422), `tests/e2e/test_backtest_happy_path.py` (백필 후 단순 KJB 백테스트 성공). + +--- + +### BUG-02. 데이터 탐색 차트 버튼 런타임 에러 + +**증상** +`/admin/data/explorer` 의 "차트" 버튼 클릭 시 페이지 전체가 화이트스크린으로 떨어짐. + +**원인 추정 (코드 점검 결과)** +- `viewPrices()`는 `/api/data/stocks/{ticker}/prices` 호출 후 결과를 단일 배열로 받음(`PricePoint[]`). +- 백엔드가 객체(`{ items: [...] }`) 또는 `null` 을 반환하거나 길이 0인 경우, `.length`/`.reverse()` 등에서 에러가 나기 전에 컴포넌트가 unhandled exception 으로 죽을 수 있음. +- 또한 `prices` 가 `null`/`undefined` 인 상태에서 `formatNumber(p.volume ?? null)` 가 안전하지 않은 옵셔널 chaining 진입을 한다. + +**대응 (0.5d)** +1. `viewPrices` 응답 가드: `Array.isArray(result) ? result : (result?.items ?? [])` 로 정규화. +2. 컴포넌트 트리에 `error boundary` 추가 — `frontend/src/components/error-boundary.tsx` 신설 후 `DashboardLayout` 또는 explorer 페이지를 감쌈. 에러 시 "차트 로드에 실패했습니다 — 새로고침" 안내 + Sentry/console 로깅. +3. 빈 데이터 처리는 이미 있으나, 가격 0건일 때 차트 모듈로 진입하지 않도록 명시적 분기. +4. 백엔드 `app/api/data_explorer.py` 의 `prices` 응답 스키마를 `PriceSeries(items: list[PricePoint])` 로 통일하고 OpenAPI 갱신. +5. e2e: Playwright 시나리오 — 가격이 없는 ticker 차트 클릭 → 에러 바운더리가 잡고 빈 상태 카드가 보이는지 확인. + +--- + +## 2. P1 버그 + +### BUG-03. PriceCollector 작업 상태 미업데이트 + +**증상**: 5/6, 5/7, 5/8 PriceCollector 잡이 `running` 으로 잔류. + +**대응 (0.5d)** +1. `app/services/collectors/base.py` 의 잡 실행 함수에 컨텍스트 매니저 도입: + ```python + with track_job_execution(job_name) as ctx: + ctx.start() + try: ... + except Exception as e: ctx.fail(e); raise + finally: ctx.complete_if_running() + ``` + - `complete_if_running()` 가 `running` 인 경우 `failed_unknown` 로 마무리. +2. APScheduler 잡에 `misfire_grace_time`, `coalesce=True`, `max_instances=1` 명시. +3. **데이터 정리 마이그레이션**: 5/6~5/8 `running` 레코드를 `failed_orphaned` 로 일괄 업데이트하는 1회성 SQL 스크립트(`alembic/versions/2026_05_10_orphaned_jobs.py`). +4. 헬스 체크: `data_collection_jobs.last_heartbeat` 컬럼 추가 → 30분 이상 갱신 안 되면 워치독이 자동 `failed` 처리. +5. 프론트(`/admin/data`): 24시간 이상 `running` 인 작업은 빨간 배지 + 툴팁 노출. + +**검증**: `pytest tests/unit/test_collector_lifecycle.py` 신규 — 정상/예외/타임아웃 케이스에서 상태 전이가 올바른지 확인. + +--- + +### BUG-04. `/admin` → `/admin/data` 리다이렉트 + +**대응 (0.1d)** +- 옵션 A: `frontend/src/app/admin/page.tsx` 신설 후 `redirect('/admin/data')` (Next.js App Router server redirect). +- 옵션 B: `next.config.ts` 에 `redirects()` 추가. +- 옵션 A 채택(데이터 셋업이 admin 첫 화면이라는 의도가 명확). +- 검증: 직접 URL 접근 시 클라이언트 라우터가 `/admin/data` 로 즉시 이동. + +--- + +## 3. P1 — 미노출 페이지 진입 경로 노출 + +리포트 § A 의 7개 페이지를 사이드바 또는 컨텍스트 위치에 연결한다. 사이드바 과밀화를 막기 위해 **상위 네비 + 서브 탭/내부 링크** 조합으로 분류한다. + +| URL | 노출 위치 (1차) | 노출 위치 (2차) | +|---|---|---| +| `/portfolio/[id]/correlation` | 포트폴리오 상세 상단 탭 "분석" 안에 서브탭 | — | +| `/portfolio/[id]/benchmark` | 포트폴리오 상세 "분석" 서브탭 | — | +| `/portfolio/[id]/drawdown` | 포트폴리오 상세 "분석" 서브탭 | 대시보드 위젯에 링크 | +| `/portfolio/[id]/history` | 포트폴리오 상세 상단 탭 "수익률 히스토리" | — | +| `/strategy/optimizer` | 전략 페이지 헤더 우측 버튼 "파라미터 최적화" | 전략 카드에서도 컨텍스트 링크 | +| `/pension/tax-simulator` | 퇴직연금 페이지 헤더 우측 버튼 "세금 시뮬레이터" | 사이드바 퇴직연금 하위 | +| `/backtest/compare` | 백테스트 페이지 결과 리스트에서 "비교에 추가" → "비교 보기" 버튼 활성화 | — | + +**구현 단계 (1.5d)** +1. `frontend/src/app/portfolio/[id]/layout.tsx` (또는 page) 에 탭 네비 정의: 개요 / 보유종목 / 거래내역 / 분석(서브탭: 자산배분·상관관계·벤치마크·드로우다운) / 히스토리. +2. `frontend/src/components/layout/new-sidebar.tsx` 의 `navItems` 는 변경 없이, 각 도메인 페이지에 진입 버튼/탭으로 노출 (사이드바 일관성 유지). +3. `/strategy` 페이지 헤더에 "파라미터 최적화" 버튼 추가 (`Link href="/strategy/optimizer"`). +4. `/pension` 페이지 헤더에 "세금 시뮬레이터" 버튼 추가. +5. `/backtest` 결과 카드의 비교 버튼 동작 확인 — 결과가 1건 이상이면 enabled, 클릭 시 `/backtest/compare?ids=...` 이동. +6. 각 페이지의 빈 상태(데이터 부재) 처리 점검 — BUG-01 백필 전이라도 화이트스크린 없이 안내 카드 표시. +7. e2e: 사이드바/페이지 네비 클릭 시 각 URL 정상 진입 확인. + +--- + +## 4. P1 — 백엔드 전용 기능 프론트엔드 구현 + +### 4-A. AI 에이전트 채팅 화면 (3~5d) + +**API**: `backend/app/api/agents.py` — `POST /api/agent` SSE 스트리밍, tool call 로그 포함. + +**UI 설계** +- 신규 라우트 `/agent` (사이드바 아이콘 — `Sparkles`/`Bot` 아이콘 추가). +- 좌: 대화 입력창 + 메시지 타임라인, 우: tool call 로그 / 참조한 데이터 사이드 패널 (접기 가능). +- 메시지 타임라인 항목 종류: + - 사용자 메시지 + - 에이전트 텍스트 응답 (스트리밍 중 토큰별 append) + - tool call 카드 (도구명, 인자, 결과 요약, 펼치면 raw JSON) +- 첨부: 자주 쓰는 프롬프트 프리셋(보유 종목 리스크 점검 / 신호 해설 / 포트폴리오 요약). +- 세션 저장: 단기는 in-memory + URL query, 후속으로 DB 모델(`agent_conversations`) 도입은 별도 티켓. + +**구현 단계** +1. `frontend/src/lib/api.ts` 에 SSE 헬퍼 (`streamAgent({ message, onChunk, onToolCall })`). +2. `frontend/src/app/agent/page.tsx` + `components/agent/{ChatTimeline,ToolCallCard,PromptPresets}.tsx`. +3. 사이드바 진입 추가. +4. 백엔드 검수: `agents.py` 응답이 SSE 표준(`data: ...\n\n`)인지 확인, 인증 토큰 헤더 처리, 에러 메시지 정규화. +5. e2e: 단일 라운드(질문→응답 텍스트 노출), tool call 발생 시나리오, 네트워크 단절 시 재시도 UX. + +### 4-B. KJB 스크리닝 화면 (2~3d) + +**API**: `backend/app/api/screening.py` — `today`, `history`, `watchlist`, `auto-orders`. + +**UI 설계 — 신규 페이지 `/screening`** +- 상단 KPI: 오늘 추천 종목 수, 매수 후보, 워치리스트 수, 자동 주문 활성/비활성. +- 탭: 오늘의 시그널 / 이력 / 워치리스트 / 자동 주문 설정. +- "오늘의 시그널" 표: 종목, 점수, 진입가, 손절가, 보유기간(예상), 사유, "워치리스트 추가" 버튼. +- "워치리스트": 추가/제거 + 메모. +- "자동 주문 설정": 토글 + 한도(최대 종목 수, 종목당 비중, 일일 매수 한도). + +**구현 단계** +1. 사이드바에 `/screening` 추가 (아이콘 `ScanSearch`/`Filter`). +2. 페이지 + 4개 탭 컴포넌트. +3. 자동 주문 토글은 명확한 confirm 모달 + 활성화 시 위험 안내. +4. e2e: 탭 전환, 워치리스트 추가/삭제, 자동 주문 설정 저장 후 새로고침 시 유지. + +--- + +## 5. 일정 / 마일스톤 + +| 일자 | 마일스톤 | 비고 | +|---|---|---| +| D+0 ~ D+1 | 핫픽스 스프린트 (BUG-01 임시 가드 + BUG-02 + BUG-04) | 즉시 배포 | +| D+2 ~ D+3 | BUG-03 + 미노출 페이지 7종 진입 경로 노출 | 백필 잡 백그라운드 실행 | +| D+3 ~ D+4 | 가격 데이터 백필 검증 + BUG-01 정상화 e2e | 백테스트 정상 동작 확인 | +| D+5 ~ D+10 | AI 에이전트 화면 | 백엔드 검수 1일 포함 | +| D+8 ~ D+12 | KJB 스크리닝 화면 | 에이전트와 병렬 가능 | +| D+12 | 종합 리그레션 + 새 테스트 리포트 작성 | 본 계획서 종료 | + +--- + +## 6. 공통 작업 / 리스크 + +- **테스트 보강**: 신규 e2e 시나리오는 모두 `backend/tests/e2e/` (백엔드) 와 `frontend/tests/e2e/` (Playwright, 미존재 시 신설) 에 추가. +- **타입 체크**: 프론트 변경 후 `cd frontend && npx tsc --noEmit` 필수 (CLAUDE.md 규칙). +- **Alembic 마이그레이션**: BUG-03 의 `last_heartbeat` 컬럼은 마이그레이션 후 `alembic upgrade head` 즉시 적용. +- **롤백 플랜**: BUG-01 백필 잡은 별도 batch 로 실행 → 실패 시 데이터 정합 영향 없음 (Price 테이블은 upsert). +- **리스크**: + - pykrx rate-limit 으로 백필 시간이 예상보다 길어질 수 있음 → 4년치를 분기별로 쪼개 야간 실행. + - AI 에이전트 SSE 가 reverse proxy(현재 duckdns 도메인) 에서 버퍼링될 가능성 → nginx `proxy_buffering off;` 확인 필요. +- **보안**: AI 에이전트 / 자동 주문은 인증된 admin 역할에만 노출, 자동 주문 토글 변경은 감사 로그 기록. + +--- + +## 7. 산출물 체크리스트 + +- [ ] `backend/app/api/backtest.py` 422 응답 + e2e +- [ ] `backend/app/api/data_explorer.py` price 응답 정규화 +- [ ] `frontend/src/components/error-boundary.tsx` 신설 + explorer 적용 +- [ ] `frontend/src/app/admin/page.tsx` redirect +- [ ] 포트폴리오 상세 탭/서브탭 리팩터 +- [ ] `/strategy/optimizer`, `/pension/tax-simulator` 진입 버튼 +- [ ] `/backtest/compare` 비교 흐름 활성화 +- [ ] `frontend/src/app/agent/**` (AI 에이전트 UI) +- [ ] `frontend/src/app/screening/**` (스크리닝 UI) +- [ ] `data_collection_jobs.last_heartbeat` 마이그레이션 + 워치독 +- [ ] 가격 데이터 백필 잡 실행 / 검증 보고 +- [ ] 새 e2e 테스트 통과 +- [ ] `docs/test-report-2026-05-XX.md` 재작성 (모든 항목 ✅) diff --git a/docs/test-report-2026-05-09.md b/docs/test-report-2026-05-09.md new file mode 100644 index 0000000..bb74020 --- /dev/null +++ b/docs/test-report-2026-05-09.md @@ -0,0 +1,140 @@ +# Galaxis-Po 배포 테스트 리포트 + +**테스트 일시**: 2026-05-09 (토) +**테스트 URL**: https://galaxis.ayuriel.duckdns.org +**테스터**: Claude (Cowork) + +--- + +## 전체 결과 요약 + +| 페이지 / 기능 | 상태 | 비고 | +|---|---|---| +| 로그인 / 인증 | ✅ 정상 | | +| 대시보드 | ✅ 정상 | | +| 포트폴리오 목록 | ✅ 정상 | | +| 포트폴리오 상세 (보유종목·거래내역·분석) | ✅ 정상 | | +| 전략 목록 | ✅ 정상 | | +| KJB 전략 실행 (종목 랭킹) | ✅ 정상 | | +| 매매 신호 (오늘) | ✅ 정상 | 주말이라 신호 0건 — 정상 | +| 매매 신호 이력 | ✅ 정상 | | +| 백테스트 실행 | ❌ 실패 | No trading days found | +| 데이터 수집 관리 (`/admin/data`) | ✅ 정상 | | +| 데이터 탐색 차트 | ❌ 실패 | 런타임 에러 | + +--- + +## ✅ 정상 동작 상세 + +### 대시보드 +- 총 자산: ₩78,627,545 +- 총 수익률: +32.3% (+₩19,203,619) +- 활성 포트폴리오: 3개 +- 보유 중인 종목: 5개 +- 자산 배분 도넛 차트 정상 렌더링 + +### 포트폴리오 +- 연금 포트폴리오 (퇴직연금): ₩78,627,545, +32.32%, 5종목 보유 + - ACE KRX금현물 (14.5%, +41.15%) + - TIGER 미국S&P500 (18.0%, +25.38%) + - KIWOOM 국고채10년 (20.6%, -7.31%) + - TIGER 200 (2.4%, +241.95%) + - KODEX 200미국채혼합 (44.6%, +58.89%) +- 거래내역 탭: 2026-03-23 최근 매수 이력 정상 표시 +- 분석 탭: 자산 배분 차트 + 목표 vs 실제 비중 비교 정상 + +### 전략 +- 멀티 팩터, 슈퍼 퀄리티, 밸류 모멘텀, 김종봉 단기매매 4개 카드 정상 +- KJB 전략 실행: 30/30 종목 (기준일 2026-05-09), 팩터 스코어 순위 정상 + - 1위: LS ELECTRIC (산업재, 종합 86.55) + +### 매매 신호 +- 오늘의 신호: 매수 0, 매도 0, 부분매도 0 (주말 - 정상) +- 신호 이력: 2026-05-07 HD현대중공업(329180) 매수, 진입가 693,000, large_candle 사유 + +### 데이터 수집 +- 수집 작업 6종 정상 표시 (종목 마스터, 섹터 정보, 가격 데이터, 밸류 지표, ETF 마스터, ETF 가격) +- 주식 마스터: 2,787건 수집 완료 +- ETFPriceCollector: 52,038건 수집 성공 + +--- + +## ❌ 버그 상세 + +### BUG-01: 백테스트 전면 실패 (심각) + +**증상**: 모든 전략, 모든 날짜 범위에서 실패 +**에러 메시지**: `백테스트 실패: No trading days found in the specified period` +**재현**: 백테스트 설정에서 어떤 전략을 선택해도 실행 즉시 실패 +**근본 원인**: +`backend/app/services/backtest/engine.py:316`의 `_get_trading_days()`가 `Price` 테이블에서 해당 기간 날짜를 조회하는데, DB에 2020~2024년 가격 데이터가 없어 빈 리스트 반환 → 에러 발생 +```python +def _get_trading_days(self, start_date: date, end_date: date) -> List[date]: + prices = self.db.query(Price.date).filter(...).distinct().all() + return [p[0] for p in prices] # 2020~2024 데이터 없으면 [] +``` +**수정 방향**: +1. (근본 해결) pykrx로 2020~2024년 과거 가격 데이터 소급 수집 +2. (임시 조치) 백테스트 기본 날짜를 실제 보유 데이터 기간으로 변경 + UI에서 사용 가능 날짜 범위 안내 + +### BUG-02: 데이터 탐색 차트 버튼 런타임 에러 + +**증상**: `/admin/data/explorer`에서 종목 옆 "차트" 버튼 클릭 시 전체 페이지 에러 화면 표시 +**에러**: "문제가 발생했습니다. 페이지를 새로고침 해주세요." +**재현**: 데이터 탐색 → 삼성전자 검색 → 차트 클릭 +**추정 원인**: 차트 모달/컴포넌트 렌더링 중 unhandled exception (가격 데이터 없음 또는 props 타입 오류) + +### BUG-03: PriceCollector 작업 상태 미업데이트 + +**증상**: 5/6, 5/7, 5/8 날짜의 PriceCollector가 `running` 상태로 잔류 (완료/실패 처리 안 됨) +**영향**: 작업이 실제로 수집됐는지 여부 불투명, 모니터링 신뢰도 저하 +**추정 원인**: PriceCollector 작업이 완료/에러 시 DB 상태를 업데이트하지 않거나, 프로세스가 비정상 종료됨 + +### BUG-04: `/admin` 경로 404 + +**증상**: `/admin` 직접 접근 시 404 +**실제 경로**: `/admin/data` +**영향**: 낮음 (사이드바에서 접근하면 정상) +**수정 방향**: `/admin` → `/admin/data` 리다이렉트 추가 + +--- + +--- + +## 구현됐지만 UI에 미노출된 기능 + +### A. 프론트엔드 페이지 존재, 진입 경로 없음 (직접 URL 접근만 가능) + +| URL | 기능 | 연결 방식 | +|---|---|---| +| `/portfolio/[id]/correlation` | 상관관계 분석 (상관관계 매트릭스, 분산화 점수) | 사이드바·포트폴리오 상세 어디에도 링크 없음 | +| `/portfolio/[id]/benchmark` | 벤치마크 비교 (KOSPI 대비 수익률, Sharpe, MDD 비교) | 진입 경로 없음 | +| `/portfolio/[id]/drawdown` | 드로우다운 분석 (현재·최대 MDD, 경보 한도, 히스토리 차트) | 진입 경로 없음 | +| `/portfolio/[id]/history` | 포트폴리오 수익률 히스토리 (별도 전체 페이지) | 진입 경로 없음 | +| `/strategy/optimizer` | 전략 파라미터 최적화 (그리드 서치, CAGR/MDD/Sharpe 기준 랭킹) | 전략 페이지에 링크 없음 | +| `/pension/tax-simulator` | 퇴직연금 세금 시뮬레이터 (세액공제, 연금 vs 일시금 세금 비교) | 퇴직연금 페이지에 링크 없음 | +| `/backtest/compare` | 백테스트 결과 비교 (여러 백테스트 동시 비교) | 백테스트 "비교" 버튼 존재하나 백테스트 결과가 없어 접근 불가 | +| `/strategy/compare` | 전략 비교 페이지 | 전략 페이지 "전략 비교" 버튼으로 접근 가능 (노출됨) | + +### B. 백엔드 API 구현 완료, 프론트엔드 페이지 없음 + +| API 파일 | 기능 | 상태 | +|---|---|---| +| `backend/app/api/agents.py` | 자연어 쿼리 기반 AI 투자 분석 에이전트 (`/api/agent`) — 스트리밍 응답, tool call 로그 포함 | 백엔드만 구현, 프론트엔드 완전 미구현 | +| `backend/app/api/screening.py` | KJB 종목 스크리닝 신호 (`/api/screening/today`, `/api/screening/history`, `/api/screening/watchlist`, `/api/screening/auto-orders`) | 백엔드만 구현, 프론트엔드 완전 미구현 | + +### C. 요약 + +- **직접 URL로 접근은 가능하나 진입 경로가 없는 페이지**: 7개 + → 포트폴리오 분석 3종(상관관계·벤치마크·드로우다운), 포트폴리오 히스토리, 전략 최적화, 퇴직연금 세금 시뮬레이터, 백테스트 비교 +- **백엔드만 구현된 기능**: 2개 (AI 에이전트, 스크리닝) +- **이미 UI에 노출된 기능 중 작동 안 하는 것**: 백테스트 전체 (BUG-01) + +--- + +## 환경 정보 + +- 주식 마스터: 2,787건 +- ETF 가격: 52,038건 +- 포트폴리오: 3개 (연금 2개, 키움 일반 1개) +- 가격 데이터 범위: 2025년 이후로 추정 (2020~2024 데이터 없음) diff --git a/frontend/src/app/admin/data/explorer/page.tsx b/frontend/src/app/admin/data/explorer/page.tsx index 09427c4..9f0b016 100644 --- a/frontend/src/app/admin/data/explorer/page.tsx +++ b/frontend/src/app/admin/data/explorer/page.tsx @@ -6,6 +6,7 @@ import { DashboardLayout } from '@/components/layout/dashboard-layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { ErrorBoundary } from '@/components/error-boundary'; import { api } from '@/lib/api'; import { toast } from 'sonner'; @@ -128,8 +129,15 @@ export default function DataExplorerPage() { const endpoint = type === 'stock' ? `/api/data/stocks/${ticker}/prices` : `/api/data/etfs/${ticker}/prices`; - const result = await api.get(endpoint); - setPrices(result); + // API returns PriceSeries { items, total, skip, limit } + const result = await api.get<{ items: PricePoint[] } | PricePoint[]>(endpoint); + const items: PricePoint[] = Array.isArray(result) + ? result + : (result?.items ?? []); + if (items.length === 0) { + toast.error(`${ticker} 가격 데이터가 없습니다.`); + } + setPrices(items); } catch { toast.error('가격 데이터를 불러오는데 실패했습니다.'); setPrices([]); @@ -338,6 +346,7 @@ export default function DataExplorerPage() { {/* Price Chart / Table */} {selectedTicker && ( + {selectedTicker} 가격 데이터 ({prices.length}건) @@ -391,6 +400,7 @@ export default function DataExplorerPage() { )} + )} ); diff --git a/frontend/src/app/admin/data/page.tsx b/frontend/src/app/admin/data/page.tsx index c68d72c..47476a0 100644 --- a/frontend/src/app/admin/data/page.tsx +++ b/frontend/src/app/admin/data/page.tsx @@ -117,11 +117,18 @@ export default function DataManagementPage() { const colors: Record = { success: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + failed_orphaned: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', running: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', }; return colors[status] || 'bg-muted text-muted-foreground'; }; + const isStaleRunning = (job: JobLog) => { + if (job.status !== 'running') return false; + const started = new Date(job.started_at).getTime(); + return Date.now() - started > 24 * 60 * 60 * 1000; + }; + const hasRunningJobs = jobs.some((j) => j.status === 'running'); if (loading) { @@ -209,11 +216,13 @@ export default function DataManagementPage() { {jobs.map((job) => ( - + {job.job_name} - - {job.status} + + {job.status}{isStaleRunning(job) ? ' ⚠' : ''} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..09ce4f8 --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function AdminPage() { + redirect('/admin/data'); +} diff --git a/frontend/src/app/agent/page.tsx b/frontend/src/app/agent/page.tsx new file mode 100644 index 0000000..207e275 --- /dev/null +++ b/frontend/src/app/agent/page.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { DashboardLayout } from '@/components/layout/dashboard-layout'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { api } from '@/lib/api'; +import { toast } from 'sonner'; +import { Sparkles, Send, ChevronDown, ChevronRight, Bot, User, Loader2, X } from 'lucide-react'; + +// ────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────── +interface ToolCall { + tool_name: string; + params: Record; + result: string; + error?: string | null; +} + +type MessageRole = 'user' | 'assistant' | 'tool_call'; + +interface Message { + id: string; + role: MessageRole; + content: string; + toolCalls?: ToolCall[]; + streaming?: boolean; +} + +// ────────────────────────────────────────────────────────── +// Prompt presets +// ────────────────────────────────────────────────────────── +const PRESETS = [ + { label: '포트폴리오 요약', prompt: '현재 포트폴리오의 종목 구성과 수익률을 요약해줘.' }, + { label: '리스크 점검', prompt: '보유 종목의 리스크를 점검하고 주의할 점을 알려줘.' }, + { label: '신호 해설', prompt: '최근 매매 신호를 해설하고 이유를 설명해줘.' }, + { label: '시장 동향', prompt: '오늘 주요 시장 동향과 퀀트 관점 코멘트를 알려줘.' }, +]; + +// ────────────────────────────────────────────────────────── +// ToolCallCard +// ────────────────────────────────────────────────────────── +function ToolCallCard({ tc }: { tc: ToolCall }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+
+

인자

+
{JSON.stringify(tc.params, null, 2)}
+
+
+

{tc.error ? '오류' : '결과'}

+
+              {tc.error ?? tc.result}
+            
+
+
+ )} +
+ ); +} + +// ────────────────────────────────────────────────────────── +// ChatTimeline +// ────────────────────────────────────────────────────────── +function ChatTimeline({ messages }: { messages: Message[] }) { + const bottomRef = useRef(null); + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + if (messages.length === 0) { + return ( +
+ +

질문을 입력하거나 프리셋을 선택하세요.

+
+ ); + } + + return ( +
+ {messages.map((msg) => ( +
+
+ {msg.role === 'user' ? : } +
+
+
+ {msg.streaming && msg.content === '' ? ( + + ) : ( + {msg.content} + )} + {msg.streaming && msg.content !== '' && ( + + )} +
+ {msg.toolCalls?.map((tc, i) => )} +
+
+ ))} +
+
+ ); +} + +// ────────────────────────────────────────────────────────── +// Main page +// ────────────────────────────────────────────────────────── +export default function AgentPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [streaming, setStreaming] = useState(false); + const abortRef = useRef(null); + const idCounter = useRef(0); + const nextId = () => String(++idCounter.current); + + useEffect(() => { + api.getCurrentUser().catch(() => router.push('/login')).finally(() => setLoading(false)); + }, [router]); + + const stopStream = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setStreaming(false); + // Mark last assistant message as no longer streaming + setMessages((prev) => prev.map((m) => m.streaming ? { ...m, streaming: false } : m)); + }, []); + + const sendMessage = useCallback(async (text: string) => { + if (!text.trim() || streaming) return; + + const userMsg: Message = { id: nextId(), role: 'user', content: text.trim() }; + const assistantId = nextId(); + const assistantMsg: Message = { id: assistantId, role: 'assistant', content: '', streaming: true, toolCalls: [] }; + + setMessages((prev) => [...prev, userMsg, assistantMsg]); + setInput(''); + setStreaming(true); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + await api.streamAgent( + text.trim(), + 'strong', + (event) => { + if (event.type === 'token') { + const token = (event.data as { text?: string }).text ?? ''; + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, content: m.content + token } : m, + ), + ); + } else if (event.type === 'response') { + const responseText = (event.data as { text?: string }).text ?? ''; + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, content: responseText } : m, + ), + ); + } else if (event.type === 'tool_end') { + const d = event.data as { tool_name?: string; params?: Record; result?: string; error?: string }; + const tc: ToolCall = { + tool_name: d.tool_name ?? '', + params: d.params ?? {}, + result: d.result ?? '', + error: d.error ?? null, + }; + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, toolCalls: [...(m.toolCalls ?? []), tc] } + : m, + ), + ); + } else if (event.type === 'done' || event.type === 'error') { + if (event.type === 'error') { + const errMsg = (event.data as { message?: string }).message ?? '에이전트 오류'; + toast.error(errMsg); + } + } + }, + controller.signal, + ); + } catch (err) { + if ((err as Error).name !== 'AbortError') { + toast.error(err instanceof Error ? err.message : '에이전트 연결 실패'); + } + } finally { + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, streaming: false } : m)), + ); + setStreaming(false); + abortRef.current = null; + } + }, [streaming]); + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(input); + } + }; + + if (loading) return null; + + return ( + +
+
+

+ + AI 투자 에이전트 +

+

자연어로 포트폴리오를 분석하고 인사이트를 얻으세요.

+
+ {streaming && ( + + )} +
+ + {/* Preset buttons */} +
+ {PRESETS.map((p) => ( + + ))} +
+ + {/* Chat area */} + + + 대화 + + + + +
+