diff --git a/.env.example b/.env.example index 645a13b..1445b76 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,7 @@ # Copy this file to .env and fill in the values # Database -DB_USER=galaxy -DB_PASSWORD=your_secure_password_here -DB_NAME=galaxy_po +DATABASE_URL=postgresql://galaxy:your_secure_password_here@localhost:5432/galaxy_po # JWT Authentication JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters diff --git a/backend/alembic/versions/add_missing_indexes.py b/backend/alembic/versions/add_missing_indexes.py new file mode 100644 index 0000000..b1bdfff --- /dev/null +++ b/backend/alembic/versions/add_missing_indexes.py @@ -0,0 +1,42 @@ +"""add missing performance indexes + +Revision ID: c3d4e5f6a7b8 +Revises: b7c8d9e0f1a2 +Create Date: 2026-03-19 10:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c3d4e5f6a7b8" +down_revision: Union[str, None] = "59807c4e84ee" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Stock universe filtering (strategy engine uses market + market_cap frequently) + op.create_index("idx_stocks_market", "stocks", ["market"]) + op.create_index( + "idx_stocks_market_cap", "stocks", [sa.text("market_cap DESC NULLS LAST")] + ) + + # Backtest listing by user (always filtered by user_id + ordered by created_at) + op.create_index( + "idx_backtests_user_created", + "backtests", + ["user_id", sa.text("created_at DESC")], + ) + op.create_index("idx_backtests_status", "backtests", ["status"]) + + +def downgrade() -> None: + op.drop_index("idx_backtests_status", table_name="backtests") + op.drop_index("idx_backtests_user_created", table_name="backtests") + op.drop_index("idx_stocks_market_cap", table_name="stocks") + op.drop_index("idx_stocks_market", table_name="stocks") diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 58ae1ed..6a07b39 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -1,6 +1,7 @@ """ Backtest API endpoints. """ + from typing import List from fastapi import APIRouter, Depends, HTTPException @@ -9,14 +10,26 @@ from sqlalchemy.orm import Session, joinedload from app.core.database import get_db from app.api.deps import CurrentUser from app.models.backtest import ( - Backtest, BacktestResult, BacktestEquityCurve, - BacktestHolding, BacktestTransaction, BacktestStatus, + Backtest, + BacktestResult, + BacktestEquityCurve, + BacktestHolding, + BacktestTransaction, + BacktestStatus, WalkForwardResult, ) from app.schemas.backtest import ( - BacktestCreate, BacktestResponse, BacktestListItem, BacktestMetrics, - EquityCurvePoint, RebalanceHoldings, HoldingItem, TransactionItem, - WalkForwardRequest, WalkForwardWindowResult, WalkForwardResponse, + BacktestCreate, + BacktestResponse, + BacktestListItem, + BacktestMetrics, + EquityCurvePoint, + RebalanceHoldings, + HoldingItem, + TransactionItem, + WalkForwardRequest, + WalkForwardWindowResult, + WalkForwardResponse, ) from app.services.backtest import submit_backtest from app.services.backtest.walkforward_engine import WalkForwardEngine @@ -97,14 +110,15 @@ async def get_backtest( db: Session = Depends(get_db), ): """Get backtest details and results.""" - backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() + backtest = ( + db.query(Backtest) + .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id) + .first() + ) if not backtest: raise HTTPException(status_code=404, detail="Backtest not found") - if backtest.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized") - result_metrics = None if backtest.result: result_metrics = BacktestMetrics( @@ -144,14 +158,15 @@ async def get_equity_curve( db: Session = Depends(get_db), ): """Get equity curve data for chart.""" - backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() + backtest = ( + db.query(Backtest) + .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id) + .first() + ) if not backtest: raise HTTPException(status_code=404, detail="Backtest not found") - if backtest.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized") - curve = ( db.query(BacktestEquityCurve) .filter(BacktestEquityCurve.backtest_id == backtest_id) @@ -177,14 +192,15 @@ async def get_holdings( db: Session = Depends(get_db), ): """Get holdings at each rebalance date.""" - backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() + backtest = ( + db.query(Backtest) + .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id) + .first() + ) if not backtest: raise HTTPException(status_code=404, detail="Backtest not found") - if backtest.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized") - holdings = ( db.query(BacktestHolding) .filter(BacktestHolding.backtest_id == backtest_id) @@ -197,13 +213,15 @@ async def get_holdings( for h in holdings: if h.rebalance_date not in grouped: grouped[h.rebalance_date] = [] - grouped[h.rebalance_date].append(HoldingItem( - ticker=h.ticker, - name=h.name, - weight=h.weight, - shares=h.shares, - price=h.price, - )) + grouped[h.rebalance_date].append( + HoldingItem( + ticker=h.ticker, + name=h.name, + weight=h.weight, + shares=h.shares, + price=h.price, + ) + ) return [ RebalanceHoldings(rebalance_date=date, holdings=items) @@ -218,14 +236,15 @@ async def get_transactions( db: Session = Depends(get_db), ): """Get all transactions.""" - backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() + backtest = ( + db.query(Backtest) + .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id) + .first() + ) if not backtest: raise HTTPException(status_code=404, detail="Backtest not found") - if backtest.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized") - transactions = ( db.query(BacktestTransaction) .filter(BacktestTransaction.backtest_id == backtest_id) @@ -261,14 +280,15 @@ async def run_walkforward( db: Session = Depends(get_db), ): """Run walk-forward analysis on a completed backtest.""" - backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() + backtest = ( + db.query(Backtest) + .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id) + .first() + ) if not backtest: raise HTTPException(status_code=404, detail="Backtest not found") - if backtest.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized") - if backtest.status != BacktestStatus.COMPLETED: raise HTTPException( status_code=400, @@ -296,14 +316,15 @@ async def get_walkforward( db: Session = Depends(get_db), ): """Get walk-forward analysis results.""" - backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() + backtest = ( + db.query(Backtest) + .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id) + .first() + ) if not backtest: raise HTTPException(status_code=404, detail="Backtest not found") - if backtest.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized") - results = ( db.query(WalkForwardResult) .filter(WalkForwardResult.backtest_id == backtest_id) @@ -336,14 +357,15 @@ async def delete_backtest( db: Session = Depends(get_db), ): """Delete a backtest and all its data.""" - backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() + backtest = ( + db.query(Backtest) + .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id) + .first() + ) if not backtest: raise HTTPException(status_code=404, detail="Backtest not found") - if backtest.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized") - # Delete related data db.query(WalkForwardResult).filter( WalkForwardResult.backtest_id == backtest_id @@ -357,9 +379,7 @@ async def delete_backtest( db.query(BacktestEquityCurve).filter( BacktestEquityCurve.backtest_id == backtest_id ).delete() - db.query(BacktestResult).filter( - BacktestResult.backtest_id == backtest_id - ).delete() + db.query(BacktestResult).filter(BacktestResult.backtest_id == backtest_id).delete() db.delete(backtest) db.commit() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f53e367..ce87aa1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,6 +1,7 @@ """ Application configuration using Pydantic Settings. """ + from pydantic_settings import BaseSettings from functools import lru_cache @@ -11,10 +12,10 @@ class Settings(BaseSettings): debug: bool = False # Database - database_url: str = "postgresql://galaxy:devpassword@localhost:5432/galaxy_po" + database_url: str # JWT - jwt_secret: str = "dev-jwt-secret-change-in-production" + jwt_secret: str jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 # 24 hours diff --git a/backend/app/core/database.py b/backend/app/core/database.py index b7d952e..696656a 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,18 +1,18 @@ """ Database connection and session management. """ + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base from app.core.config import get_settings settings = get_settings() -engine = create_engine( - settings.database_url, - pool_pre_ping=True, - pool_size=10, - max_overflow=20, -) +_engine_kwargs = {"pool_pre_ping": True} +if settings.database_url.startswith("postgresql"): + _engine_kwargs.update(pool_size=10, max_overflow=20) + +engine = create_engine(settings.database_url, **_engine_kwargs) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/app/services/backtest/engine.py b/backend/app/services/backtest/engine.py index 1c63547..701bc3b 100644 --- a/backend/app/services/backtest/engine.py +++ b/backend/app/services/backtest/engine.py @@ -1,6 +1,7 @@ """ Main backtest engine. """ + import logging from dataclasses import dataclass, field from datetime import date, timedelta @@ -12,13 +13,21 @@ from sqlalchemy import func from sqlalchemy.orm import Session from app.models.backtest import ( - Backtest, BacktestResult, BacktestEquityCurve, - BacktestHolding, BacktestTransaction, RebalancePeriod, + Backtest, + BacktestResult, + BacktestEquityCurve, + BacktestHolding, + BacktestTransaction, + RebalancePeriod, ) from app.models.stock import Stock, Price from app.services.backtest.portfolio import VirtualPortfolio, Transaction from app.services.backtest.metrics import MetricsCalculator -from app.services.strategy import MultiFactorStrategy, QualityStrategy, ValueMomentumStrategy +from app.services.strategy import ( + MultiFactorStrategy, + QualityStrategy, + ValueMomentumStrategy, +) from app.schemas.strategy import UniverseFilter, FactorWeights logger = logging.getLogger(__name__) @@ -27,6 +36,7 @@ logger = logging.getLogger(__name__) @dataclass class DataValidationResult: """Result of pre-backtest data validation.""" + is_valid: bool = True errors: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list) @@ -85,9 +95,7 @@ class BacktestEngine: logger.warning(f"Backtest {backtest_id}: {warning}") if not validation.is_valid: - raise ValueError( - "데이터 검증 실패:\n" + "\n".join(validation.errors) - ) + raise ValueError("데이터 검증 실패:\n" + "\n".join(validation.errors)) # Create strategy instance strategy = self._create_strategy( @@ -105,20 +113,24 @@ class BacktestEngine: if initial_benchmark == 0: initial_benchmark = Decimal("1") + names = self._get_stock_names() + all_date_prices = self._load_all_prices_by_date( + backtest.start_date, + backtest.end_date, + ) + for trading_date in trading_days: - # Get prices for this date - prices = self._get_prices_for_date(trading_date) - names = self._get_stock_names() + prices = all_date_prices.get(trading_date, {}) # Warn about holdings with missing prices missing = [ - t for t in portfolio.holdings + t + for t in portfolio.holdings if portfolio.holdings[t] > 0 and t not in prices ] if missing: logger.warning( - f"{trading_date}: 보유 종목 가격 누락 {missing} " - f"(0원으로 처리됨)" + f"{trading_date}: 보유 종목 가격 누락 {missing} (0원으로 처리됨)" ) # Rebalance if needed @@ -141,16 +153,16 @@ class BacktestEngine: slippage_rate=backtest.slippage_rate, ) - all_transactions.extend([ - (trading_date, txn) for txn in transactions - ]) + all_transactions.extend([(trading_date, txn) for txn in transactions]) # Record holdings holdings = portfolio.get_holdings_with_weights(prices, names) - holdings_history.append({ - 'date': trading_date, - 'holdings': holdings, - }) + holdings_history.append( + { + "date": trading_date, + "holdings": holdings, + } + ) # Record daily value portfolio_value = portfolio.get_value(prices) @@ -161,15 +173,21 @@ class BacktestEngine: benchmark_value / initial_benchmark * backtest.initial_capital ) - equity_curve_data.append({ - 'date': trading_date, - 'portfolio_value': portfolio_value, - 'benchmark_value': normalized_benchmark, - }) + equity_curve_data.append( + { + "date": trading_date, + "portfolio_value": portfolio_value, + "benchmark_value": normalized_benchmark, + } + ) # Calculate metrics - portfolio_values = [Decimal(str(e['portfolio_value'])) for e in equity_curve_data] - benchmark_values = [Decimal(str(e['benchmark_value'])) for e in equity_curve_data] + portfolio_values = [ + Decimal(str(e["portfolio_value"])) for e in equity_curve_data + ] + benchmark_values = [ + Decimal(str(e["benchmark_value"])) for e in equity_curve_data + ] metrics = MetricsCalculator.calculate_all(portfolio_values, benchmark_values) drawdowns = MetricsCalculator.calculate_drawdown_series(portfolio_values) @@ -221,18 +239,13 @@ class BacktestEngine: # 2. Benchmark data coverage benchmark_ticker = "069500" if benchmark == "KOSPI" else "069500" - benchmark_coverage = sum( - 1 for d in total_days if d in benchmark_prices - ) + benchmark_coverage = sum(1 for d in total_days if d in benchmark_prices) benchmark_pct = ( - benchmark_coverage / num_trading_days * 100 - if num_trading_days > 0 else 0 + benchmark_coverage / num_trading_days * 100 if num_trading_days > 0 else 0 ) if benchmark_coverage == 0: - result.errors.append( - f"벤치마크({benchmark_ticker}) 가격 데이터 없음" - ) + result.errors.append(f"벤치마크({benchmark_ticker}) 가격 데이터 없음") result.is_valid = False elif benchmark_pct < 90: result.warnings.append( @@ -254,22 +267,17 @@ class BacktestEngine: .scalar() ) if ticker_count == 0: - result.errors.append( - f"{sample_date} 가격 데이터 없음 (종목 0개)" - ) + result.errors.append(f"{sample_date} 가격 데이터 없음 (종목 0개)") result.is_valid = False elif ticker_count < 100: - result.warnings.append( - f"{sample_date} 종목 수 적음: {ticker_count}개" - ) + result.warnings.append(f"{sample_date} 종목 수 적음: {ticker_count}개") # 4. Large gaps in trading days (> 7 calendar days excluding normal weekends) for i in range(1, num_trading_days): gap = (total_days[i] - total_days[i - 1]).days if gap > 7: result.warnings.append( - f"거래일 갭 발견: {total_days[i-1]} ~ {total_days[i]} " - f"({gap}일)" + f"거래일 갭 발견: {total_days[i - 1]} ~ {total_days[i]} ({gap}일)" ) if result.is_valid and not result.warnings: @@ -338,18 +346,25 @@ class BacktestEngine: return {p.date: p.close for p in prices} - def _get_prices_for_date(self, trading_date: date) -> Dict[str, Decimal]: - """Get all stock prices for a specific date.""" + def _load_all_prices_by_date( + self, + start_date: date, + end_date: date, + ) -> Dict[date, Dict[str, Decimal]]: prices = ( self.db.query(Price) - .filter(Price.date == trading_date) + .filter(Price.date >= start_date, Price.date <= end_date) .all() ) - return {p.ticker: p.close for p in prices} + result: Dict[date, Dict[str, Decimal]] = {} + for p in prices: + if p.date not in result: + result[p.date] = {} + result[p.date][p.ticker] = p.close + return result def _get_stock_names(self) -> Dict[str, str]: - """Get all stock names.""" - stocks = self.db.query(Stock).all() + stocks = self.db.query(Stock.ticker, Stock.name).all() return {s.ticker: s.name for s in stocks} def _create_strategy( @@ -367,8 +382,12 @@ class BacktestEngine: strategy._min_fscore = strategy_params.get("min_fscore", 7) elif strategy_type == "value_momentum": strategy = ValueMomentumStrategy(self.db) - strategy._value_weight = Decimal(str(strategy_params.get("value_weight", 0.5))) - strategy._momentum_weight = Decimal(str(strategy_params.get("momentum_weight", 0.5))) + strategy._value_weight = Decimal( + str(strategy_params.get("value_weight", 0.5)) + ) + strategy._momentum_weight = Decimal( + str(strategy_params.get("momentum_weight", 0.5)) + ) else: raise ValueError(f"Unknown strategy type: {strategy_type}") @@ -401,19 +420,19 @@ class BacktestEngine: for i, point in enumerate(equity_curve_data): curve_point = BacktestEquityCurve( backtest_id=backtest_id, - date=point['date'], - portfolio_value=point['portfolio_value'], - benchmark_value=point['benchmark_value'], + date=point["date"], + portfolio_value=point["portfolio_value"], + benchmark_value=point["benchmark_value"], drawdown=drawdowns[i] if i < len(drawdowns) else Decimal("0"), ) self.db.add(curve_point) # Save holdings history for record in holdings_history: - for holding in record['holdings']: + for holding in record["holdings"]: h = BacktestHolding( backtest_id=backtest_id, - rebalance_date=record['date'], + rebalance_date=record["date"], ticker=holding.ticker, name=holding.name, weight=holding.weight, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8833992..6df81ec 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,10 +1,14 @@ """ Pytest configuration and fixtures for E2E tests. """ + import os import pytest from typing import Generator +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("JWT_SECRET", "test-secret-key-for-pytest-only") + from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session diff --git a/frontend/src/app/admin/data/explorer/page.tsx b/frontend/src/app/admin/data/explorer/page.tsx index 9558df6..09427c4 100644 --- a/frontend/src/app/admin/data/explorer/page.tsx +++ b/frontend/src/app/admin/data/explorer/page.tsx @@ -97,8 +97,7 @@ export default function DataExplorerPage() { const endpoint = `/api/data/${tab}?${params}`; const result = await api.get>(endpoint); setData(result); - } catch (err) { - console.error('Failed to fetch data:', err); + } catch { toast.error('데이터를 불러오는데 실패했습니다.'); setData(null); } finally { @@ -131,8 +130,7 @@ export default function DataExplorerPage() { : `/api/data/etfs/${ticker}/prices`; const result = await api.get(endpoint); setPrices(result); - } catch (err) { - console.error('Failed to fetch prices:', err); + } catch { toast.error('가격 데이터를 불러오는데 실패했습니다.'); setPrices([]); } finally { diff --git a/frontend/src/app/backtest/compare/page.tsx b/frontend/src/app/backtest/compare/page.tsx new file mode 100644 index 0000000..75a188d --- /dev/null +++ b/frontend/src/app/backtest/compare/page.tsx @@ -0,0 +1,419 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { + Area, + AreaChart as RechartsAreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { DashboardLayout } from '@/components/layout/dashboard-layout'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { api } from '@/lib/api'; +import { toast } from 'sonner'; + +interface BacktestListItem { + id: number; + strategy_type: string; + start_date: string; + end_date: string; + rebalance_period: string; + status: string; + created_at: string; + total_return: number | null; + cagr: number | null; + mdd: number | null; +} + +interface BacktestDetail { + id: number; + strategy_type: string; + start_date: string; + end_date: string; + status: string; + result: { + total_return: number; + cagr: number; + mdd: number; + sharpe_ratio: number; + volatility: number; + benchmark_return: number; + excess_return: number; + } | null; +} + +interface EquityCurvePoint { + date: string; + portfolio_value: number; + benchmark_value: number; + drawdown: number; +} + +interface CompareData { + detail: BacktestDetail; + equityCurve: EquityCurvePoint[]; +} + +const STRATEGY_LABELS: Record = { + multi_factor: '멀티 팩터', + quality: '슈퍼 퀄리티', + value_momentum: '밸류 모멘텀', + kjb: '김종봉 단기매매', +}; + +const COMPARE_COLORS = ['#3b82f6', '#ef4444', '#22c55e']; + +const METRICS = [ + { key: 'total_return', label: '총 수익률', suffix: '%' }, + { key: 'cagr', label: 'CAGR', suffix: '%' }, + { key: 'mdd', label: 'MDD', suffix: '%' }, + { key: 'sharpe_ratio', label: '샤프 비율', suffix: '' }, + { key: 'volatility', label: '변동성', suffix: '%' }, + { key: 'benchmark_return', label: '벤치마크 수익률', suffix: '%' }, + { key: 'excess_return', label: '초과 수익률', suffix: '%' }, +] as const; + +export default function BacktestComparePage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [backtests, setBacktests] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [compareData, setCompareData] = useState([]); + const [comparing, setComparing] = useState(false); + + useEffect(() => { + const init = async () => { + try { + await api.getCurrentUser(); + const data = await api.get('/api/backtest'); + setBacktests(data.filter((bt) => bt.status === 'completed')); + } catch { + router.push('/login'); + } finally { + setLoading(false); + } + }; + init(); + }, [router]); + + const toggleSelection = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else if (next.size < 3) { + next.add(id); + } else { + toast.error('최대 3개까지 선택할 수 있습니다.'); + } + return next; + }); + }; + + const handleCompare = async () => { + if (selectedIds.size < 2) { + toast.error('비교할 백테스트를 2개 이상 선택하세요.'); + return; + } + setComparing(true); + try { + const ids = Array.from(selectedIds); + const results = await Promise.all( + ids.map(async (id) => { + const [detail, equityCurve] = await Promise.all([ + api.get(`/api/backtest/${id}`), + api.get(`/api/backtest/${id}/equity-curve`), + ]); + return { detail, equityCurve }; + }) + ); + setCompareData(results); + } catch (err) { + toast.error(err instanceof Error ? err.message : '비교 데이터를 불러오는데 실패했습니다.'); + } finally { + setComparing(false); + } + }; + + const getStrategyLabel = (type: string) => STRATEGY_LABELS[type] || type; + + const formatNumber = (value: number | null | undefined, decimals: number = 2) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(decimals); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW', + maximumFractionDigits: 0, + }).format(value); + }; + + const buildChartData = () => { + if (compareData.length === 0) return []; + + const dateMap = new Map>(); + + compareData.forEach((cd, idx) => { + for (const point of cd.equityCurve) { + const existing = dateMap.get(point.date) || {}; + existing[`value_${idx}`] = point.portfolio_value; + dateMap.set(point.date, existing); + } + }); + + return Array.from(dateMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, values]) => ({ date, ...values })); + }; + + const getCompareLabel = (idx: number) => { + const cd = compareData[idx]; + return `${getStrategyLabel(cd.detail.strategy_type)} (${cd.detail.start_date.slice(0, 4)}~${cd.detail.end_date.slice(0, 4)})`; + }; + + if (loading) { + return ( + + + + + ); + } + + const chartData = buildChartData(); + + return ( + +
+
+

백테스트 비교

+

+ 완료된 백테스트를 선택하여 성과를 비교하세요 (최대 3개) +

+
+ +
+ + + + 백테스트 선택 + + + {backtests.length === 0 ? ( +

+ 완료된 백테스트가 없습니다. +

+ ) : ( + <> +
+ {backtests.map((bt) => ( + + ))} +
+ + + )} +
+
+ + {compareData.length >= 2 && ( + <> + + + 성과 비교 + + +
+ + + + + {compareData.map((cd, idx) => ( + + ))} + + + + {METRICS.map((metric) => ( + + + {compareData.map((cd) => { + const value = cd.detail.result + ? cd.detail.result[metric.key as keyof typeof cd.detail.result] + : null; + const numValue = typeof value === 'number' ? value : null; + const isNegativeMetric = metric.key === 'mdd'; + const colorClass = numValue !== null + ? isNegativeMetric + ? 'text-red-600' + : numValue >= 0 + ? 'text-green-600' + : 'text-red-600' + : ''; + return ( + + ); + })} + + ))} + +
+ 지표 + + {getCompareLabel(idx)} +
+ {metric.label} + + {formatNumber(numValue)}{numValue !== null ? metric.suffix : ''} +
+
+
+
+ + + + 자산 추이 비교 + + + {chartData.length > 0 ? ( +
+ + + + {compareData.map((_, idx) => ( + + + + + ))} + + + v.slice(5)} + stroke="hsl(var(--muted-foreground))" + fontSize={12} + tickLine={false} + axisLine={false} + /> + formatCurrency(v)} + stroke="hsl(var(--muted-foreground))" + fontSize={12} + tickLine={false} + axisLine={false} + width={100} + /> + { + const idx = parseInt(String(name).replace('value_', '')); + return [formatCurrency(Number(value)), getCompareLabel(idx)]; + }} + /> + { + const idx = parseInt(value.replace('value_', '')); + return getCompareLabel(idx); + }} + /> + {compareData.map((_, idx) => ( + + ))} + + +
+ ) : ( +
+

차트 데이터가 없습니다

+
+ )} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/app/backtest/page.tsx b/frontend/src/app/backtest/page.tsx index 134a490..6e6feb7 100644 --- a/frontend/src/app/backtest/page.tsx +++ b/frontend/src/app/backtest/page.tsx @@ -17,9 +17,18 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; import { AreaChart } from '@/components/charts/area-chart'; import { api } from '@/lib/api'; -import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings } from 'lucide-react'; +import { toast } from 'sonner'; +import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings, Trash2, GitCompareArrows } from 'lucide-react'; interface BacktestResult { id: number; @@ -148,7 +157,7 @@ export default function BacktestPage() { } } } catch (err) { - console.error('Failed to fetch backtests:', err); + toast.error(err instanceof Error ? err.message : '백테스트 목록을 불러오는데 실패했습니다.'); } }; @@ -238,6 +247,34 @@ export default function BacktestPage() { }).format(value); }; + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [deleting, setDeleting] = useState(false); + + const handleDeleteClick = (id: number) => { + setDeleteTargetId(id); + setDeleteConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (deleteTargetId === null) return; + setDeleting(true); + try { + await api.delete(`/api/backtest/${deleteTargetId}`); + setBacktests((prev) => prev.filter((bt) => bt.id !== deleteTargetId)); + if (currentResult?.id === deleteTargetId) { + setCurrentResult(null); + } + setDeleteConfirmOpen(false); + setDeleteTargetId(null); + toast.success('백테스트가 삭제되었습니다.'); + } catch (err) { + toast.error(err instanceof Error ? err.message : '백테스트 삭제에 실패했습니다.'); + } finally { + setDeleting(false); + } + }; + const displayResult = currentResult; if (loading) { @@ -263,13 +300,21 @@ export default function BacktestPage() { 전략의 과거 성과를 분석하세요

- +
+ + +
{error && ( @@ -297,6 +342,7 @@ export default function BacktestPage() { MDD 상태 생성일 + 삭제 @@ -328,11 +374,21 @@ export default function BacktestPage() { {new Date(bt.created_at).toLocaleDateString('ko-KR')} + + + ))} {backtests.length === 0 && ( - + 아직 백테스트가 없습니다. @@ -771,6 +827,24 @@ export default function BacktestPage() { )} + + + + 백테스트 삭제 + + 이 백테스트를 삭제하면 복구할 수 없습니다. 계속하시겠습니까? + + + + + + + + ); } diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index 315eb64..c34e094 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -127,6 +127,7 @@ export default function PortfolioDetailPage() { tx_type: 'buy', quantity: '', price: '', + executed_at: '', memo: '', }); @@ -237,7 +238,7 @@ export default function PortfolioDetailPage() { }; const handleAddTransaction = async () => { - if (!txForm.ticker || !txForm.quantity || !txForm.price) return; + if (!txForm.ticker || !txForm.quantity || !txForm.price || !txForm.executed_at) return; setTxSubmitting(true); try { await api.post(`/api/portfolios/${portfolioId}/transactions`, { @@ -245,11 +246,11 @@ export default function PortfolioDetailPage() { tx_type: txForm.tx_type, quantity: parseInt(txForm.quantity, 10), price: parseFloat(txForm.price), - executed_at: new Date().toISOString(), + executed_at: new Date(txForm.executed_at).toISOString(), memo: txForm.memo || null, }); setTxModalOpen(false); - setTxForm({ ticker: '', tx_type: 'buy', quantity: '', price: '', memo: '' }); + setTxForm({ ticker: '', tx_type: 'buy', quantity: '', price: '', executed_at: '', memo: '' }); await Promise.all([fetchPortfolio(), fetchTransactions()]); } catch (err) { const message = err instanceof Error ? err.message : '거래 추가 실패'; @@ -790,6 +791,15 @@ export default function PortfolioDetailPage() { /> +
+ + setTxForm({ ...txForm, executed_at: e.target.value })} + /> +
diff --git a/frontend/src/app/portfolio/[id]/rebalance/page.tsx b/frontend/src/app/portfolio/[id]/rebalance/page.tsx index e9f43ae..fc23a6c 100644 --- a/frontend/src/app/portfolio/[id]/rebalance/page.tsx +++ b/frontend/src/app/portfolio/[id]/rebalance/page.tsx @@ -64,6 +64,8 @@ export default function RebalancePage() { const [applyPrices, setApplyPrices] = useState>({}); const [applying, setApplying] = useState(false); const [applyError, setApplyError] = useState(null); + const [portfolioType, setPortfolioType] = useState('general'); + const [currentRiskRatio, setCurrentRiskRatio] = useState(null); useEffect(() => { const init = async () => { @@ -87,16 +89,21 @@ export default function RebalancePage() { }); setPrices(initialPrices); - // Fetch stock names from portfolio detail try { - const detail = await api.get<{ holdings: { ticker: string; name: string | null }[] }>(`/api/portfolios/${portfolioId}/detail`); + const detail = await api.get<{ + portfolio_type: string; + risk_asset_ratio: number | null; + holdings: { ticker: string; name: string | null }[]; + }>(`/api/portfolios/${portfolioId}/detail`); const names: Record = {}; for (const h of detail.holdings) { if (h.name) names[h.ticker] = h.name; } setNameMap(names); + setPortfolioType(detail.portfolio_type); + setCurrentRiskRatio(detail.risk_asset_ratio); } catch { - // Names are optional, continue without + // ignore } } catch { router.push('/login'); @@ -316,7 +323,19 @@ export default function RebalancePage() { - {/* Results */} + {result && portfolioType === 'pension' && currentRiskRatio !== null && currentRiskRatio > 70 && ( +
+ +
+

DC형 퇴직연금 위험자산 비율 초과 경고

+

+ 현재 위험자산 비율: {currentRiskRatio.toFixed(1)}% (법적 한도: 70%). + 리밸런싱 시 채권형/금 ETF 비중을 늘려 위험자산 비율을 조정하세요. +

+
+
+ )} + {result && ( <> diff --git a/frontend/src/app/signals/page.tsx b/frontend/src/app/signals/page.tsx index 5182e5c..11ee04e 100644 --- a/frontend/src/app/signals/page.tsx +++ b/frontend/src/app/signals/page.tsx @@ -158,8 +158,7 @@ export default function SignalsPage() { try { const data = await api.get('/api/signal/kjb/today'); setTodaySignals(data); - } catch (err) { - console.error('Failed to fetch today signals:', err); + } catch { toast.error('오늘의 신호를 불러오는데 실패했습니다.'); } }; @@ -174,8 +173,7 @@ export default function SignalsPage() { const url = `/api/signal/kjb/history${query ? `?${query}` : ''}`; const data = await api.get(url); setHistorySignals(data); - } catch (err) { - console.error('Failed to fetch signal history:', err); + } catch { toast.error('신호 이력을 불러오는데 실패했습니다.'); } }; @@ -184,8 +182,7 @@ export default function SignalsPage() { try { const data = await api.get('/api/portfolios'); setPortfolios(data); - } catch (err) { - console.error('Failed to fetch portfolios:', err); + } catch { toast.error('포트폴리오 목록을 불러오는데 실패했습니다.'); } }; @@ -254,8 +251,7 @@ export default function SignalsPage() { if (ps.recommended_quantity > 0) { setExecuteQuantity(String(ps.recommended_quantity)); } - } catch (err) { - console.error('Failed to fetch position sizing:', err); + } catch { toast.error('포지션 사이징 정보를 불러오는데 실패했습니다.'); } } diff --git a/frontend/src/app/strategy/kjb/page.tsx b/frontend/src/app/strategy/kjb/page.tsx index 484c3db..11aaa77 100644 --- a/frontend/src/app/strategy/kjb/page.tsx +++ b/frontend/src/app/strategy/kjb/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; +import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio'; interface StockFactor { ticker: string; @@ -191,6 +192,9 @@ export default function KJBStrategyPage() {
+
+ ({ ticker: s.ticker, name: s.name }))} /> +
)} diff --git a/frontend/src/components/strategy/apply-to-portfolio.tsx b/frontend/src/components/strategy/apply-to-portfolio.tsx index c66ae7b..046ff89 100644 --- a/frontend/src/components/strategy/apply-to-portfolio.tsx +++ b/frontend/src/components/strategy/apply-to-portfolio.tsx @@ -1,9 +1,26 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { api } from '@/lib/api'; +import { toast } from 'sonner'; interface Portfolio { id: number; @@ -21,19 +38,18 @@ interface ApplyToPortfolioProps { } export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) { + const router = useRouter(); const [portfolios, setPortfolios] = useState([]); - const [selectedId, setSelectedId] = useState(null); - const [showConfirm, setShowConfirm] = useState(false); + const [selectedId, setSelectedId] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); const [applying, setApplying] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); useEffect(() => { const load = async () => { try { const data = await api.get('/api/portfolios'); setPortfolios(data); - if (data.length > 0) setSelectedId(data[0].id); + if (data.length > 0) setSelectedId(String(data[0].id)); } catch { // ignore } @@ -41,10 +57,10 @@ export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) { load(); }, []); - const apply = async () => { - if (!selectedId || stocks.length === 0) return; + const handleApply = async () => { + const portfolioId = Number(selectedId); + if (!portfolioId || stocks.length === 0) return; setApplying(true); - setError(null); try { const ratio = parseFloat((100 / stocks.length).toFixed(2)); const targets: TargetItem[] = stocks.map((s, i) => ({ @@ -54,12 +70,16 @@ export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) { : ratio, })); - await api.put(`/api/portfolios/${selectedId}/targets`, targets); - setShowConfirm(false); - setSuccess(true); - setTimeout(() => setSuccess(false), 3000); + await api.put(`/api/portfolios/${portfolioId}/targets`, targets); + setDialogOpen(false); + toast.success('목표 배분이 적용되었습니다.', { + action: { + label: '리밸런싱으로 이동', + onClick: () => router.push(`/portfolio/${portfolioId}/rebalance`), + }, + }); } catch (err) { - setError(err instanceof Error ? err.message : '적용 실패'); + toast.error(err instanceof Error ? err.message : '목표 배분 적용에 실패했습니다.'); } finally { setApplying(false); } @@ -68,71 +88,58 @@ export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) { if (portfolios.length === 0) return null; return ( -
-
-
- - -
- -
+ <> + - {success && ( -
- 목표 배분이 적용되었습니다. -
- )} + + + + 목표 배분 적용 + + 선택한 포트폴리오의 기존 목표 배분을 덮어씁니다. + {stocks.length}개 종목이 동일 비중({(100 / stocks.length).toFixed(2)}%)으로 설정됩니다. + + - {showConfirm && ( -
-
-
-

목표 배분 적용 확인

-

- 선택한 포트폴리오의 기존 목표 배분을 덮어씁니다. - {stocks.length}개 종목이 동일 비중({(100 / stocks.length).toFixed(2)}%)으로 설정됩니다. -

+
+
+ + +
- {error && ( -
- {error} +
+ {stocks.map((s) => ( +
+ {s.name || s.ticker} + {(100 / stocks.length).toFixed(2)}%
- )} - -
- {stocks.map((s) => ( -
- {s.name || s.ticker} - {(100 / stocks.length).toFixed(2)}% -
- ))} -
- -
- - -
+ ))}
-
- )} -
+ + + + + + +
+ ); }