From f6db08c9bd9b638d53920f3f4c46f55dbe96d612 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?=
Date: Fri, 20 Mar 2026 12:27:05 +0900
Subject: [PATCH] feat: improve security, performance, and add missing features
- Remove hardcoded database_url/jwt_secret defaults, require env vars
- Add DB indexes for stocks.market, market_cap, backtests.user_id
- Optimize backtest engine: preload all prices, move stock_names out of loop
- Fix backtest API auth: filter by user_id at query level (6 endpoints)
- Add manual transaction entry modal on portfolio detail page
- Replace console.error with toast.error in signals, backtest, data explorer
- Add backtest delete button with confirmation dialog
- Replace simulated sine chart with real snapshot data
- Add strategy-to-portfolio apply flow with dialog
- Add DC pension risk asset ratio >70% warning on rebalance page
- Add backtest comparison page with metrics table and overlay chart
---
.env.example | 4 +-
.../alembic/versions/add_missing_indexes.py | 42 ++
backend/app/api/backtest.py | 106 +++--
backend/app/core/config.py | 5 +-
backend/app/core/database.py | 12 +-
backend/app/services/backtest/engine.py | 129 +++---
backend/tests/conftest.py | 4 +
frontend/src/app/admin/data/explorer/page.tsx | 6 +-
frontend/src/app/backtest/compare/page.tsx | 419 ++++++++++++++++++
frontend/src/app/backtest/page.tsx | 94 +++-
frontend/src/app/portfolio/[id]/page.tsx | 18 +-
.../src/app/portfolio/[id]/rebalance/page.tsx | 27 +-
frontend/src/app/signals/page.tsx | 12 +-
frontend/src/app/strategy/kjb/page.tsx | 4 +
.../strategy/apply-to-portfolio.tsx | 153 ++++---
15 files changed, 823 insertions(+), 212 deletions(-)
create mode 100644 backend/alembic/versions/add_missing_indexes.py
create mode 100644 frontend/src/app/backtest/compare/page.tsx
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) => (
+
+ {getCompareLabel(idx)}
+ |
+ ))}
+
+
+
+ {METRICS.map((metric) => (
+
+ |
+ {metric.label}
+ |
+ {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 (
+
+ {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)];
+ }}
+ />
+
+
+
+ ) : (
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
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 && (
-
- 목표 배분이 적용되었습니다.
-
- )}
+