fix: 테스트 리포트 보완 계획 전체 구현
Some checks failed
Deploy to Production / deploy (push) Failing after 2m38s

This commit is contained in:
머니페니 2026-05-10 16:44:05 +09:00
parent 76e3220e77
commit 120a8546cb
24 changed files with 1637 additions and 164 deletions

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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",

View File

@ -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(

View File

@ -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}")

View File

@ -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

View File

@ -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

View File

@ -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([

View File

@ -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` 재작성 (모든 항목 ✅)

View File

@ -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 데이터 없음)

View File

@ -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<PricePoint[]>(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 && (
<ErrorBoundary inline>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{selectedTicker} ({prices.length})</CardTitle>
@ -391,6 +400,7 @@ export default function DataExplorerPage() {
)}
</CardContent>
</Card>
</ErrorBoundary>
)}
</DashboardLayout>
);

View File

@ -117,11 +117,18 @@ export default function DataManagementPage() {
const colors: Record<string, string> = {
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() {
</thead>
<tbody className="divide-y divide-border">
{jobs.map((job) => (
<tr key={job.id}>
<tr key={job.id} className={isStaleRunning(job) ? 'bg-red-50 dark:bg-red-950/20' : ''}>
<td className="px-4 py-3 text-sm">{job.job_name}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs ${getStatusBadge(job.status)}`}>
{job.status}
<span className={`px-2 py-1 rounded text-xs ${isStaleRunning(job) ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' : getStatusBadge(job.status)}`}
title={isStaleRunning(job) ? '24시간 이상 running 상태 — 비정상 종료 가능성 있음' : undefined}
>
{job.status}{isStaleRunning(job) ? ' ⚠' : ''}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AdminPage() {
redirect('/admin/data');
}

View File

@ -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<string, unknown>;
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 (
<div className="rounded border border-border bg-muted/40 text-xs mt-2">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left"
onClick={() => setOpen(!open)}
>
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
<span className="font-mono font-medium">{tc.tool_name}</span>
{tc.error && <Badge variant="destructive" className="ml-auto text-xs"></Badge>}
</button>
{open && (
<div className="border-t border-border px-3 py-2 space-y-2">
<div>
<p className="text-muted-foreground mb-1"></p>
<pre className="whitespace-pre-wrap font-mono bg-background rounded p-2">{JSON.stringify(tc.params, null, 2)}</pre>
</div>
<div>
<p className="text-muted-foreground mb-1">{tc.error ? '오류' : '결과'}</p>
<pre className="whitespace-pre-wrap font-mono bg-background rounded p-2 max-h-40 overflow-y-auto">
{tc.error ?? tc.result}
</pre>
</div>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────
// ChatTimeline
// ──────────────────────────────────────────────────────────
function ChatTimeline({ messages }: { messages: Message[] }) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
if (messages.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm"> .</p>
</div>
);
}
return (
<div className="space-y-4 py-2">
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
<div className={`h-7 w-7 shrink-0 rounded-full flex items-center justify-center ${msg.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{msg.role === 'user' ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</div>
<div className={`max-w-[80%] ${msg.role === 'user' ? 'items-end' : 'items-start'} flex flex-col`}>
<div className={`rounded-lg px-4 py-2.5 text-sm leading-relaxed ${msg.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{msg.streaming && msg.content === '' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<span className="whitespace-pre-wrap">{msg.content}</span>
)}
{msg.streaming && msg.content !== '' && (
<span className="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5 align-middle" />
)}
</div>
{msg.toolCalls?.map((tc, i) => <ToolCallCard key={i} tc={tc} />)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
);
}
// ──────────────────────────────────────────────────────────
// Main page
// ──────────────────────────────────────────────────────────
export default function AgentPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(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<string, unknown>; 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<HTMLTextAreaElement>): void => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(input);
}
};
if (loading) return null;
return (
<DashboardLayout>
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="h-6 w-6" />
AI
</h1>
<p className="mt-1 text-muted-foreground text-sm"> .</p>
</div>
{streaming && (
<Button variant="outline" size="sm" onClick={stopStream}>
<X className="h-4 w-4 mr-1" />
</Button>
)}
</div>
{/* Preset buttons */}
<div className="flex flex-wrap gap-2 mb-4">
{PRESETS.map((p) => (
<Button
key={p.label}
variant="outline"
size="sm"
disabled={streaming}
onClick={() => sendMessage(p.prompt)}
>
{p.label}
</Button>
))}
</div>
{/* Chat area */}
<Card className="flex flex-col" style={{ height: 'calc(100vh - 280px)', minHeight: '400px' }}>
<CardHeader className="py-3 border-b shrink-0">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto p-4">
<ChatTimeline messages={messages} />
</CardContent>
<div className="border-t p-3 shrink-0 flex gap-2 items-end">
<Textarea
className="min-h-[60px] max-h-[160px] resize-none"
placeholder="질문을 입력하세요... (Shift+Enter: 줄바꿈)"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={streaming}
/>
<Button
size="icon"
disabled={streaming || !input.trim()}
onClick={() => sendMessage(input)}
className="h-[60px] w-[60px] shrink-0"
>
{streaming ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
</Button>
</div>
</Card>
</DashboardLayout>
);
}

View File

@ -80,6 +80,12 @@ const periodOptions = [
];
interface PriceCoverage {
available_from: string | null;
available_to: string | null;
distinct_days: number;
}
export default function BacktestPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
@ -88,11 +94,12 @@ export default function BacktestPage() {
const [currentResult, setCurrentResult] = useState<BacktestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
const [coverage, setCoverage] = useState<PriceCoverage | null>(null);
// Form state
const [strategyType, setStrategyType] = useState('multi_factor');
const [startDate, setStartDate] = useState('2020-01-01');
const [endDate, setEndDate] = useState('2024-12-31');
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
const [rebalancePeriod, setRebalancePeriod] = useState('quarterly');
const [initialCapital, setInitialCapital] = useState(100000000);
const [topN, setTopN] = useState(30);
@ -119,6 +126,15 @@ export default function BacktestPage() {
const init = async () => {
try {
await api.getCurrentUser();
// Load price coverage and set default date range accordingly
try {
const cov = await api.get<PriceCoverage>('/api/data/prices/coverage');
setCoverage(cov);
if (cov.available_from) setStartDate(cov.available_from);
if (cov.available_to) setEndDate(cov.available_to);
} catch {
// Coverage unavailable — leave defaults
}
await fetchBacktests();
} catch {
router.push('/login');
@ -430,25 +446,36 @@ export default function BacktestPage() {
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="start-date"></Label>
<Input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date"></Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<div className="space-y-1">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="start-date"></Label>
<Input
id="start-date"
type="date"
value={startDate}
min={coverage?.available_from ?? undefined}
max={coverage?.available_to ?? undefined}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date"></Label>
<Input
id="end-date"
type="date"
value={endDate}
min={coverage?.available_from ?? undefined}
max={coverage?.available_to ?? undefined}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{coverage?.available_from && (
<p className="text-xs text-muted-foreground">
: {coverage.available_from} ~ {coverage.available_to} ({coverage.distinct_days.toLocaleString()})
</p>
)}
</div>
{/* Rebalancing Period (not for KJB) */}

View File

@ -86,12 +86,19 @@ export default function PensionPage() {
DC형/IRP/
</p>
</div>
<Link href="/pension/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
<div className="flex gap-2">
<Link href="/pension/tax-simulator">
<Button variant="outline">
</Button>
</Link>
<Link href="/pension/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
{/* Summary */}

View File

@ -421,6 +421,7 @@ export default function PortfolioDetailPage() {
<TabsTrigger value="holdings"></TabsTrigger>
<TabsTrigger value="transactions"></TabsTrigger>
<TabsTrigger value="analysis"></TabsTrigger>
<TabsTrigger value="history"> </TabsTrigger>
</TabsList>
{/* Holdings Tab */}
@ -663,76 +664,103 @@ export default function PortfolioDetailPage() {
{/* Analysis Tab */}
<TabsContent value="analysis">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Allocation Chart */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<DonutChart data={getDonutData()} height={250} showLegend={true} />
</CardContent>
</Card>
{/* Sub-tabs for analysis views */}
<Tabs defaultValue="allocation">
<TabsList className="mb-4">
<TabsTrigger value="allocation"> </TabsTrigger>
<TabsTrigger value="correlation" asChild>
<Link href={`/portfolio/${portfolioId}/correlation`}></Link>
</TabsTrigger>
<TabsTrigger value="benchmark" asChild>
<Link href={`/portfolio/${portfolioId}/benchmark`}></Link>
</TabsTrigger>
<TabsTrigger value="drawdown" asChild>
<Link href={`/portfolio/${portfolioId}/drawdown`}></Link>
</TabsTrigger>
</TabsList>
<TabsContent value="allocation">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Allocation Chart */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<DonutChart data={getDonutData()} height={250} showLegend={true} />
</CardContent>
</Card>
{/* Target vs Actual */}
<Card>
<CardHeader>
<CardTitle> vs </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{portfolio.targets.map((target, index) => {
const holding = portfolio.holdings.find((h) => h.ticker === target.ticker);
const actualRatio = holding?.current_ratio ?? 0;
const diff = actualRatio - target.target_ratio;
{/* Target vs Actual */}
<Card>
<CardHeader>
<CardTitle> vs </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{portfolio.targets.map((target, index) => {
const holding = portfolio.holdings.find((h) => h.ticker === target.ticker);
const actualRatio = holding?.current_ratio ?? 0;
const diff = actualRatio - target.target_ratio;
return (
<div key={target.ticker} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium" title={target.ticker}>{holding?.name || target.ticker}</span>
<span className="text-muted-foreground">
{actualRatio.toFixed(1)}% / {target.target_ratio.toFixed(1)}%
<span
className={`ml-2 ${
Math.abs(diff) > 5
? diff > 0
? 'text-orange-600'
: 'text-blue-600'
: 'text-green-600'
}`}
>
({diff >= 0 ? '+' : ''}
{diff.toFixed(1)}%)
</span>
</span>
</div>
<div className="relative h-4 bg-muted rounded-full overflow-hidden">
{/* Target indicator */}
<div
className="absolute h-full w-0.5 bg-foreground/50 z-10"
style={{ left: `${Math.min(target.target_ratio, 100)}%` }}
/>
{/* Actual bar */}
<div
className="h-full rounded-full"
style={{
width: `${Math.min(actualRatio, 100)}%`,
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
</div>
</div>
);
})}
{portfolio.targets.length === 0 && (
<p className="text-center text-muted-foreground py-8">
.
</p>
)}
</div>
</CardContent>
</Card>
</div>
return (
<div key={target.ticker} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium" title={target.ticker}>{holding?.name || target.ticker}</span>
<span className="text-muted-foreground">
{actualRatio.toFixed(1)}% / {target.target_ratio.toFixed(1)}%
<span
className={`ml-2 ${
Math.abs(diff) > 5
? diff > 0
? 'text-orange-600'
: 'text-blue-600'
: 'text-green-600'
}`}
>
({diff >= 0 ? '+' : ''}
{diff.toFixed(1)}%)
</span>
</span>
</div>
<div className="relative h-4 bg-muted rounded-full overflow-hidden">
<div
className="absolute h-full w-0.5 bg-foreground/50 z-10"
style={{ left: `${Math.min(target.target_ratio, 100)}%` }}
/>
<div
className="h-full rounded-full"
style={{
width: `${Math.min(actualRatio, 100)}%`,
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
</div>
</div>
);
})}
{portfolio.targets.length === 0 && (
<p className="text-center text-muted-foreground py-8">
.
</p>
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</TabsContent>
{/* History Tab */}
<TabsContent value="history">
<Card>
<CardContent className="p-8 flex flex-col items-center gap-3">
<p className="text-muted-foreground"> .</p>
<Link href={`/portfolio/${portfolioId}/history`}>
<Button> </Button>
</Link>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</>

View File

@ -0,0 +1,474 @@
'use client';
import { useEffect, 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 { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { ScanSearch, Star, History, Settings2, ListFilter, AlertTriangle } from 'lucide-react';
// ──────────────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────────────
interface ScreeningSignal {
id: number;
screen_date: string;
ticker: string;
name: string | null;
sector: string | null;
market_cap: number | null;
trading_value: number | null;
is_limit_up: boolean;
daily_return: number | null;
trigger_low: number | null;
market_state: string | null;
status: string;
entry_date: string | null;
entry_price: number | null;
exit_date: string | null;
exit_price: number | null;
created_at: string;
}
interface WatchlistItem {
ticker: string;
name: string | null;
sector: string | null;
screen_date: string;
trading_value: number | null;
is_limit_up: boolean;
daily_return: number | null;
trigger_low: number | null;
market_state: string | null;
status: string;
}
interface AutoOrder {
id: number;
order_date: string;
ticker: string;
order_type: string | null;
qty: number | null;
price: number | null;
order_no: string | null;
status: string | null;
screening_signal_id: number | null;
created_at: string;
}
// ──────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────
const fmt = (v: number | null | undefined, decimals = 2) =>
v !== null && v !== undefined ? v.toFixed(decimals) : '-';
const fmtKRW = (v: number | null | undefined) =>
v !== null && v !== undefined
? new Intl.NumberFormat('ko-KR').format(v)
: '-';
const statusBadge = (status: string) => {
const map: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
watching: 'bg-blue-100 text-blue-800',
entered: 'bg-green-100 text-green-800',
exited: 'bg-gray-100 text-gray-800',
cancelled: 'bg-red-100 text-red-800',
};
return map[status] ?? 'bg-muted text-muted-foreground';
};
// ──────────────────────────────────────────────────────────
// Sub-views
// ──────────────────────────────────────────────────────────
function TodaySignalsTab({ signals, loading }: { signals: ScreeningSignal[]; loading: boolean }) {
if (loading) return <Skeleton className="h-48 w-full" />;
if (signals.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<ScanSearch className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm"> .</p>
<p className="text-xs">· .</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">()</th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{signals.map((s) => (
<tr key={s.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<p className="font-medium text-sm">{s.name ?? s.ticker}</p>
<p className="text-xs text-muted-foreground font-mono">{s.ticker}</p>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{s.sector ?? '-'}</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${(s.daily_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{s.daily_return !== null ? `${fmt(s.daily_return)}%` : '-'}
</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(s.trigger_low)}</td>
<td className="px-4 py-3 text-sm text-right">
{s.trading_value !== null ? (s.trading_value / 100000000).toFixed(1) : '-'}
</td>
<td className="px-4 py-3 text-center">
{s.is_limit_up ? <Badge className="bg-red-500 text-white text-xs"></Badge> : '-'}
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${statusBadge(s.status)}`}>{s.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function HistoryTab({ signals, loading }: { signals: ScreeningSignal[]; loading: boolean }) {
if (loading) return <Skeleton className="h-48 w-full" />;
if (signals.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<History className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm"> .</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{signals.map((s) => (
<tr key={s.id} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm text-muted-foreground">{s.screen_date}</td>
<td className="px-4 py-3">
<p className="font-medium text-sm">{s.name ?? s.ticker}</p>
<p className="text-xs font-mono text-muted-foreground">{s.ticker}</p>
</td>
<td className={`px-4 py-3 text-sm text-right ${(s.daily_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{s.daily_return !== null ? `${fmt(s.daily_return)}%` : '-'}
</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(s.entry_price)}</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(s.exit_price)}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${statusBadge(s.status)}`}>{s.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function WatchlistTab({ items, loading }: { items: WatchlistItem[]; loading: boolean }) {
if (loading) return <Skeleton className="h-48 w-full" />;
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<Star className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm"> .</p>
<p className="text-xs"> pending watching인 .</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"> </th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">()</th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item, idx) => (
<tr key={`${item.ticker}-${idx}`} className="hover:bg-muted/50">
<td className="px-4 py-3">
<p className="font-medium text-sm">{item.name ?? item.ticker}</p>
<p className="text-xs font-mono text-muted-foreground">{item.ticker}</p>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{item.screen_date}</td>
<td className={`px-4 py-3 text-sm text-right ${(item.daily_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{item.daily_return !== null ? `${fmt(item.daily_return)}%` : '-'}
</td>
<td className="px-4 py-3 text-sm text-right">
{item.trading_value !== null ? (item.trading_value / 100000000).toFixed(1) : '-'}
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${statusBadge(item.status)}`}>{item.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function AutoOrdersTab({ orders, loading }: { orders: AutoOrder[]; loading: boolean }) {
if (loading) return <Skeleton className="h-48 w-full" />;
if (orders.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<Settings2 className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm"> .</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{orders.map((o) => (
<tr key={o.id} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(o.order_date).toLocaleString('ko-KR')}
</td>
<td className="px-4 py-3 text-sm font-mono">{o.ticker}</td>
<td className="px-4 py-3 text-sm">{o.order_type ?? '-'}</td>
<td className="px-4 py-3 text-sm text-right">{o.qty ?? '-'}</td>
<td className="px-4 py-3 text-sm text-right">{fmtKRW(o.price)}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${statusBadge(o.status ?? '')}`}>
{o.status ?? '-'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ──────────────────────────────────────────────────────────
// Main page
// ──────────────────────────────────────────────────────────
export default function ScreeningPage() {
const router = useRouter();
const [pageLoading, setPageLoading] = useState(true);
const [todaySignals, setTodaySignals] = useState<ScreeningSignal[]>([]);
const [history, setHistory] = useState<ScreeningSignal[]>([]);
const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
const [autoOrders, setAutoOrders] = useState<AutoOrder[]>([]);
const [todayLoading, setTodayLoading] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [watchlistLoading, setWatchlistLoading] = useState(false);
const [ordersLoading, setOrdersLoading] = useState(false);
const [executeConfirmOpen, setExecuteConfirmOpen] = useState(false);
const [executing, setExecuting] = useState(false);
const loadData = useCallback(async () => {
setTodayLoading(true);
setHistoryLoading(true);
setWatchlistLoading(true);
setOrdersLoading(true);
const results = await Promise.allSettled([
api.get<ScreeningSignal[]>('/api/screening/today'),
api.get<ScreeningSignal[]>('/api/screening/history'),
api.get<WatchlistItem[]>('/api/screening/watchlist'),
api.get<AutoOrder[]>('/api/screening/auto-orders'),
]);
if (results[0].status === 'fulfilled') setTodaySignals(results[0].value);
if (results[1].status === 'fulfilled') setHistory(results[1].value);
if (results[2].status === 'fulfilled') setWatchlist(results[2].value);
if (results[3].status === 'fulfilled') setAutoOrders(results[3].value);
setTodayLoading(false);
setHistoryLoading(false);
setWatchlistLoading(false);
setOrdersLoading(false);
}, []);
useEffect(() => {
api.getCurrentUser()
.then(loadData)
.catch(() => router.push('/login'))
.finally(() => setPageLoading(false));
}, [router, loadData]);
const handleExecute = async () => {
setExecuting(true);
try {
const result = await api.post<{ message: string; orders: unknown[] }>('/api/screening/execute');
toast.success(result.message ?? '자동 주문 실행 완료');
setExecuteConfirmOpen(false);
await loadData();
} catch (err) {
toast.error(err instanceof Error ? err.message : '자동 주문 실행 실패');
} finally {
setExecuting(false);
}
};
if (pageLoading) {
return (
<DashboardLayout>
<Skeleton className="h-8 w-64 mb-6" />
<Skeleton className="h-48 w-full" />
</DashboardLayout>
);
}
const kpiCards = [
{ label: '오늘 신호 수', value: todaySignals.length },
{ label: '워치리스트', value: watchlist.length },
{ label: '자동 주문 이력', value: autoOrders.length },
{ label: '전체 이력', value: history.length },
];
return (
<DashboardLayout>
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<ListFilter className="h-6 w-6" />
KJB
</h1>
<p className="mt-1 text-muted-foreground text-sm">
</p>
</div>
<Button
variant="outline"
onClick={() => setExecuteConfirmOpen(true)}
disabled={watchlist.length === 0}
className="shrink-0"
>
<Settings2 className="h-4 w-4 mr-2" />
</Button>
</div>
{/* KPI row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{kpiCards.map((k) => (
<Card key={k.label}>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground mb-1">{k.label}</p>
<p className="text-2xl font-bold">{k.value}</p>
</CardContent>
</Card>
))}
</div>
{/* Tabs */}
<Card>
<Tabs defaultValue="today">
<CardHeader className="pb-0">
<TabsList>
<TabsTrigger value="today">
<ScanSearch className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="history">
<History className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="watchlist">
<Star className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="auto-orders">
<Settings2 className="h-4 w-4 mr-1" />
</TabsTrigger>
</TabsList>
</CardHeader>
<CardContent className="pt-4 p-0">
<TabsContent value="today">
<TodaySignalsTab signals={todaySignals} loading={todayLoading} />
</TabsContent>
<TabsContent value="history">
<HistoryTab signals={history} loading={historyLoading} />
</TabsContent>
<TabsContent value="watchlist">
<WatchlistTab items={watchlist} loading={watchlistLoading} />
</TabsContent>
<TabsContent value="auto-orders">
<AutoOrdersTab orders={autoOrders} loading={ordersLoading} />
</TabsContent>
</CardContent>
</Tabs>
</Card>
{/* Auto-order confirm dialog */}
<Dialog open={executeConfirmOpen} onOpenChange={setExecuteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
</DialogTitle>
<DialogDescription>
({watchlist.length}) KIS API를 .
. ?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setExecuteConfirmOpen(false)} disabled={executing}>
</Button>
<Button variant="destructive" onClick={handleExecute} disabled={executing}>
{executing ? '주문 중...' : '실행'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
);
}

View File

@ -84,17 +84,26 @@ export default function StrategyListPage() {
return (
<DashboardLayout>
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground">퀀 </h1>
<p className="mt-1 text-muted-foreground">
퀀
</p>
<Link href="/strategy/compare" className="inline-block mt-3">
<Button variant="outline" size="sm">
<GitCompareArrows className="h-4 w-4 mr-2" />
</Button>
</Link>
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground">퀀 </h1>
<p className="mt-1 text-muted-foreground">
퀀
</p>
</div>
<div className="flex gap-2 mt-1 shrink-0">
<Link href="/strategy/compare">
<Button variant="outline" size="sm">
<GitCompareArrows className="h-4 w-4 mr-2" />
</Button>
</Link>
<Link href="/strategy/optimizer">
<Button variant="outline" size="sm">
</Button>
</Link>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">

View File

@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
interface ErrorBoundaryProps {
children: React.ReactNode;
/** inline=true renders a small card instead of a full-screen overlay */
inline?: boolean;
}
interface ErrorBoundaryState {
@ -31,6 +33,21 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
render() {
if (this.state.hasError) {
if (this.props.inline) {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm font-medium text-destructive"> </p>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={this.handleRetry}>
</Button>
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center space-y-4 p-8">

View File

@ -17,6 +17,8 @@ import {
Settings,
ChevronLeft,
ChevronRight,
ScanSearch,
Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@ -33,7 +35,9 @@ const navItems = [
{ href: '/portfolio', label: '포트폴리오', icon: Briefcase },
{ href: '/strategy', label: '전략', icon: TrendingUp },
{ href: '/backtest', label: '백테스트', icon: FlaskConical },
{ href: '/screening', label: 'KJB 스크리닝', icon: ScanSearch },
{ href: '/signals', label: '매매 신호', icon: Radio },
{ href: '/agent', label: 'AI 에이전트', icon: Sparkles },
{ href: '/journal', label: '트레이딩 저널', icon: BookOpen },
{ href: '/pension', label: '퇴직연금', icon: PiggyBank },
{ href: '/tools/position-sizing', label: '포지션 사이징', icon: Ruler },

View File

@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@ -87,6 +87,57 @@ class ApiClient {
async getCurrentUser() {
return this.get('/api/auth/me');
}
/**
* Open an SSE connection to the agent stream endpoint.
* Calls onEvent for each parsed SSE event object.
* Resolves when the stream ends (type === 'done' or connection closes).
*/
async streamAgent(
query: string,
modelTier: 'strong' | 'fast' = 'strong',
onEvent: (event: { type: string; data: Record<string, unknown> }) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/agent/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ query, model_tier: modelTier }),
signal,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error((error as Record<string, string>).detail || 'Agent stream failed');
}
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const parsed = JSON.parse(line.slice(6));
onEvent(parsed);
} catch {
// Malformed SSE line — ignore
}
}
}
}
}
}
export const api = new ApiClient(API_URL);