fix: 테스트 리포트 보완 계획 전체 구현
Some checks failed
Deploy to Production / deploy (push) Failing after 2m38s
Some checks failed
Deploy to Production / deploy (push) Failing after 2m38s
This commit is contained in:
parent
76e3220e77
commit
120a8546cb
@ -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
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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([
|
||||
|
||||
219
docs/plans/2026-05-09-test-report-remediation-plan.md
Normal file
219
docs/plans/2026-05-09-test-report-remediation-plan.md
Normal 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` 재작성 (모든 항목 ✅)
|
||||
140
docs/test-report-2026-05-09.md
Normal file
140
docs/test-report-2026-05-09.md
Normal 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 데이터 없음)
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
5
frontend/src/app/admin/page.tsx
Normal file
5
frontend/src/app/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect('/admin/data');
|
||||
}
|
||||
288
frontend/src/app/agent/page.tsx
Normal file
288
frontend/src/app/agent/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) */}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
474
frontend/src/app/screening/page.tsx
Normal file
474
frontend/src/app/screening/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 },
|
||||
|
||||
22
frontend/src/components/ui/textarea.tsx
Normal file
22
frontend/src/components/ui/textarea.tsx
Normal 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 };
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user